Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af1794925d | |||
| 2652736d31 | |||
| 1d79dde5e1 | |||
| 45dab8b253 | |||
| e46d833371 | |||
| dcdb0d5747 | |||
| 9b2f15151d | |||
| 7d06f149d3 | |||
| 2487f9e30f | |||
| 8f738f6469 | |||
| 7f52b2823f | |||
| c5d5a4006a | |||
| 4cb640814a | |||
| 4c98c2cce1 | |||
| db0c3a4a02 | |||
| f1756b491e | |||
| 97a9481627 | |||
| eb165db182 | |||
| 3b468b48d9 | |||
| f4583f5169 | |||
| 132f0921e0 | |||
| bb0be19dac | |||
| 15def7ff1c | |||
| 60e2ac1355 | |||
| a37d93f6cd | |||
| 7122df57b2 | |||
| 72f95aa0db | |||
| bab3453e41 | |||
| 24da1e0522 | |||
| 2203ecbdaf | |||
| 1aaab6c593 | |||
| 09bba5f8cd | |||
| 3b8dcf3af6 | |||
| 087563bce7 | |||
| e839db7331 | |||
| a83edf7667 | |||
| 75d5bbc84a | |||
| 7519f474f3 | |||
| 35494d8b32 | |||
| 4c7783884c | |||
| 8ce0b3e3e8 |
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Meezi brand palette. Green #0F6E56 matches the dashboard / Koja web.
|
||||
class MeeziColors {
|
||||
static const Color brand = Color(0xFF0F6E56);
|
||||
static const Color brandDark = Color(0xFF0B5544);
|
||||
static const Color accent = Color(0xFFE1F5EE);
|
||||
static const Color surface = Color(0xFFF9FAFB);
|
||||
}
|
||||
|
||||
/// Centralized Meezi theme. Uses Vazirmatn when the font is bundled (see pubspec);
|
||||
/// falls back to the platform font otherwise. Kept to stable Material 3 APIs.
|
||||
class MeeziTheme {
|
||||
static ThemeData light() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: MeeziColors.brand,
|
||||
primary: MeeziColors.brand,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
fontFamily: 'Vazirmatn',
|
||||
scaffoldBackgroundColor: MeeziColors.surface,
|
||||
appBarTheme: const AppBarTheme(
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: MeeziColors.brand,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: MeeziColors.brand,
|
||||
side: const BorderSide(color: MeeziColors.brand),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.black.withValues(alpha: 0.10)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.black.withValues(alpha: 0.10)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: MeeziColors.brand, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData dark() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: MeeziColors.brand,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
fontFamily: 'Vazirmatn',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,11 +93,64 @@ class _CafeDetailScreenState extends ConsumerState<CafeDetailScreen> {
|
||||
final description = cafe['description'] as String?;
|
||||
final address = cafe['address'] as String?;
|
||||
final city = cafe['city'] as String?;
|
||||
// Defensive parsing — public DTO key names may vary.
|
||||
final cover = (cafe['coverImageUrl'] ?? cafe['coverUrl'] ?? cafe['cover']) as String?;
|
||||
final isOpen = cafe['isOpenNow'] as bool?;
|
||||
final gallery = (cafe['galleryUrls'] ?? cafe['gallery']) is List
|
||||
? ((cafe['galleryUrls'] ?? cafe['gallery']) as List)
|
||||
.map((e) => e.toString())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList()
|
||||
: <String>[];
|
||||
// WorkingHoursPublicDto: a day-keyed object {sat..fri}, each {isOpen,open,close}.
|
||||
final hours = cafe['workingHours'] is Map
|
||||
? (cafe['workingHours'] as Map)
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(name, style: Theme.of(context).textTheme.headlineSmall),
|
||||
if (cover != null && cover.isNotEmpty) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Image.network(
|
||||
cover,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(color: Colors.black12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(name,
|
||||
style: Theme.of(context).textTheme.headlineSmall),
|
||||
),
|
||||
if (isOpen != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: (isOpen ? Colors.green : Colors.red)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
isOpen ? 'باز است' : 'بسته است',
|
||||
style: TextStyle(
|
||||
color: isOpen ? Colors.green[800] : Colors.red[800],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
@@ -117,6 +170,59 @@ class _CafeDetailScreenState extends ConsumerState<CafeDetailScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(description),
|
||||
],
|
||||
if (gallery.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: gallery.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
gallery[i],
|
||||
width: 150,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 150, color: Colors.black12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hours.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text('ساعات کاری',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...const [
|
||||
('sat', 'شنبه'),
|
||||
('sun', 'یکشنبه'),
|
||||
('mon', 'دوشنبه'),
|
||||
('tue', 'سهشنبه'),
|
||||
('wed', 'چهارشنبه'),
|
||||
('thu', 'پنجشنبه'),
|
||||
('fri', 'جمعه'),
|
||||
].map((d) {
|
||||
final m = hours[d.$1] is Map
|
||||
? hours[d.$1] as Map
|
||||
: const <dynamic, dynamic>{};
|
||||
final open = (m['open'] ?? '').toString();
|
||||
final close = (m['close'] ?? '').toString();
|
||||
final isOpen = m['isOpen'] == true && open.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(d.$2),
|
||||
Text(isOpen ? '$open - $close' : 'تعطیل'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
@@ -1,25 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show ValueGetter;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../cart/cart_state.dart';
|
||||
|
||||
typedef DiscoverFilters = ({String? q, double? minRating, String sort});
|
||||
/// Discovery filters. A class (not a record) so the many optional filters can be
|
||||
/// changed one at a time via copyWith without re-listing every field.
|
||||
class DiscoverFilters {
|
||||
const DiscoverFilters({
|
||||
this.q,
|
||||
this.minRating,
|
||||
this.sort = 'rating',
|
||||
this.openNow = false,
|
||||
this.priceTier,
|
||||
this.themes = const [],
|
||||
this.vibes = const [],
|
||||
this.occasions = const [],
|
||||
this.spaceFeatures = const [],
|
||||
});
|
||||
|
||||
final discoverFiltersProvider = StateProvider<DiscoverFilters>(
|
||||
(_) => (q: null, minRating: null, sort: 'rating'),
|
||||
);
|
||||
final String? q;
|
||||
final double? minRating;
|
||||
final String sort;
|
||||
final bool openNow;
|
||||
final String? priceTier;
|
||||
final List<String> themes;
|
||||
final List<String> vibes;
|
||||
final List<String> occasions;
|
||||
final List<String> spaceFeatures;
|
||||
|
||||
final discoverProvider = FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
|
||||
final filters = ref.watch(discoverFiltersProvider);
|
||||
int get activeCount =>
|
||||
(minRating != null ? 1 : 0) +
|
||||
(openNow ? 1 : 0) +
|
||||
(priceTier != null ? 1 : 0) +
|
||||
themes.length +
|
||||
vibes.length +
|
||||
occasions.length +
|
||||
spaceFeatures.length;
|
||||
|
||||
DiscoverFilters copyWith({
|
||||
ValueGetter<String?>? q,
|
||||
ValueGetter<double?>? minRating,
|
||||
String? sort,
|
||||
bool? openNow,
|
||||
ValueGetter<String?>? priceTier,
|
||||
List<String>? themes,
|
||||
List<String>? vibes,
|
||||
List<String>? occasions,
|
||||
List<String>? spaceFeatures,
|
||||
}) {
|
||||
return DiscoverFilters(
|
||||
q: q != null ? q() : this.q,
|
||||
minRating: minRating != null ? minRating() : this.minRating,
|
||||
sort: sort ?? this.sort,
|
||||
openNow: openNow ?? this.openNow,
|
||||
priceTier: priceTier != null ? priceTier() : this.priceTier,
|
||||
themes: themes ?? this.themes,
|
||||
vibes: vibes ?? this.vibes,
|
||||
occasions: occasions ?? this.occasions,
|
||||
spaceFeatures: spaceFeatures ?? this.spaceFeatures,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final discoverFiltersProvider =
|
||||
StateProvider<DiscoverFilters>((_) => const DiscoverFilters());
|
||||
|
||||
final discoverProvider =
|
||||
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
|
||||
final f = ref.watch(discoverFiltersProvider);
|
||||
return ref.watch(publicApiProvider).discover(
|
||||
city: 'تهران',
|
||||
q: filters.q,
|
||||
minRating: filters.minRating,
|
||||
sort: filters.sort,
|
||||
q: f.q,
|
||||
minRating: f.minRating,
|
||||
sort: f.sort,
|
||||
openNow: f.openNow,
|
||||
priceTier: f.priceTier,
|
||||
themes: f.themes,
|
||||
vibes: f.vibes,
|
||||
occasions: f.occasions,
|
||||
spaceFeatures: f.spaceFeatures,
|
||||
);
|
||||
});
|
||||
|
||||
/// Available themes/vibes/occasions/spaceFeatures for the filter sheet.
|
||||
final discoverTaxonomyProvider =
|
||||
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) {
|
||||
return ref.watch(publicApiProvider).discoverTaxonomy();
|
||||
});
|
||||
|
||||
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||
const DiscoverScreen({super.key});
|
||||
|
||||
@@ -39,7 +109,16 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
void _applySearch() {
|
||||
final q = _searchController.text.trim();
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: q.isEmpty ? null : q, minRating: s.minRating, sort: s.sort),
|
||||
(s) => s.copyWith(q: () => q.isEmpty ? null : q),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openFilters() async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _DiscoverFilterSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,17 +149,27 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'جستجوی نام کافه...',
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'کافه دنج برای کار، نزدیک من...',
|
||||
isDense: true,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _applySearch,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _applySearch(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Badge(
|
||||
isLabelVisible: filters.activeCount > 0,
|
||||
label: Text('${filters.activeCount}'),
|
||||
child: IconButton.filledTonal(
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: _openFilters,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -90,26 +179,29 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('همه'),
|
||||
selected: filters.minRating == null,
|
||||
onSelected: (_) {
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: null, sort: s.sort),
|
||||
);
|
||||
},
|
||||
label: const Text('باز است'),
|
||||
selected: filters.openNow,
|
||||
onSelected: (v) => ref
|
||||
.read(discoverFiltersProvider.notifier)
|
||||
.update((s) => s.copyWith(openNow: v)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: const Text('همه امتیازها'),
|
||||
selected: filters.minRating == null,
|
||||
onSelected: (_) => ref
|
||||
.read(discoverFiltersProvider.notifier)
|
||||
.update((s) => s.copyWith(minRating: () => null)),
|
||||
),
|
||||
for (final min in [3.0, 4.0, 4.5])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
label: Text('★ $min+'),
|
||||
selected: filters.minRating == min,
|
||||
onSelected: (_) {
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: min, sort: s.sort),
|
||||
);
|
||||
},
|
||||
onSelected: (_) => ref
|
||||
.read(discoverFiltersProvider.notifier)
|
||||
.update((s) => s.copyWith(minRating: () => min)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -121,7 +213,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
value: filters.sort,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'مرتبسازی',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
items: const [
|
||||
@@ -131,9 +222,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
],
|
||||
onChanged: (sort) {
|
||||
if (sort == null) return;
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: s.minRating, sort: sort),
|
||||
);
|
||||
ref
|
||||
.read(discoverFiltersProvider.notifier)
|
||||
.update((s) => s.copyWith(sort: sort));
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -143,20 +234,56 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
if (cafes.isEmpty) {
|
||||
return const Center(child: Text('کافهای یافت نشد'));
|
||||
}
|
||||
return ListView.separated(
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(discoverProvider.future),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: cafes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final cafe = cafes[index];
|
||||
itemBuilder: (context, index) =>
|
||||
_CafeCard(cafe: cafes[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('خطا: $e')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CafeCard extends StatelessWidget {
|
||||
const _CafeCard({required this.cafe});
|
||||
final Map<String, dynamic> cafe;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final slug = cafe['slug'] as String;
|
||||
final name = cafe['name'] as String? ?? slug;
|
||||
final avg = (cafe['averageRating'] as num?)?.toDouble() ?? 0;
|
||||
final count = cafe['reviewCount'] as int? ?? 0;
|
||||
final address = cafe['address'] as String?;
|
||||
final isOpen = cafe['isOpenNow'] as bool?;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(name),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
if (isOpen != null)
|
||||
Text(
|
||||
isOpen ? 'باز' : 'بسته',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isOpen ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -169,16 +296,165 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
onTap: () => context.push('/cafe/$slug'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscoverFilterSheet extends ConsumerWidget {
|
||||
const _DiscoverFilterSheet();
|
||||
|
||||
static const _priceTiers = [
|
||||
('budget', 'اقتصادی'),
|
||||
('moderate', 'متوسط'),
|
||||
('upscale', 'لاکچری'),
|
||||
('luxury', 'بسیار لاکچری'),
|
||||
];
|
||||
|
||||
List<({String key, String label})> _parseTax(dynamic raw) {
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.map<({String key, String label})>((e) {
|
||||
if (e is Map) {
|
||||
final k = (e['key'] ?? e['value'] ?? e['id'] ?? '').toString();
|
||||
final l = (e['labelFa'] ?? e['label'] ?? e['nameFa'] ?? e['name'] ?? k)
|
||||
.toString();
|
||||
return (key: k, label: l);
|
||||
}
|
||||
final s = e.toString();
|
||||
return (key: s, label: s);
|
||||
})
|
||||
.where((t) => t.key.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final filters = ref.watch(discoverFiltersProvider);
|
||||
final taxonomy = ref.watch(discoverTaxonomyProvider);
|
||||
final notifier = ref.read(discoverFiltersProvider.notifier);
|
||||
|
||||
Widget chips(String title, List<({String key, String label})> items,
|
||||
List<String> selected, void Function(List<String>) onChange) {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 6),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final it in items)
|
||||
FilterChip(
|
||||
label: Text(it.label),
|
||||
selected: selected.contains(it.key),
|
||||
onSelected: (v) {
|
||||
final next = List<String>.from(selected);
|
||||
if (v) {
|
||||
next.add(it.key);
|
||||
} else {
|
||||
next.remove(it.key);
|
||||
}
|
||||
onChange(next);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
16 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('فیلترها',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
if (filters.activeCount > 0)
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
notifier.state = const DiscoverFilters(),
|
||||
child: const Text('پاک کردن'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('فقط کافههای باز'),
|
||||
value: filters.openNow,
|
||||
onChanged: (v) => notifier.update((s) => s.copyWith(openNow: v)),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 8, 0, 6),
|
||||
child: Text('محدوده قیمت'),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final p in _priceTiers)
|
||||
ChoiceChip(
|
||||
label: Text(p.$2),
|
||||
selected: filters.priceTier == p.$1,
|
||||
onSelected: (v) => notifier.update(
|
||||
(s) => s.copyWith(priceTier: () => v ? p.$1 : null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
taxonomy.when(
|
||||
data: (tax) {
|
||||
if (tax == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
chips('فضا و حالوهوا', _parseTax(tax['themes']),
|
||||
filters.themes,
|
||||
(v) => notifier.update((s) => s.copyWith(themes: v))),
|
||||
chips('وایب', _parseTax(tax['vibes']), filters.vibes,
|
||||
(v) => notifier.update((s) => s.copyWith(vibes: v))),
|
||||
chips('مناسبت', _parseTax(tax['occasions']),
|
||||
filters.occasions,
|
||||
(v) => notifier.update((s) => s.copyWith(occasions: v))),
|
||||
chips('امکانات', _parseTax(tax['spaceFeatures']),
|
||||
filters.spaceFeatures,
|
||||
(v) => notifier.update(
|
||||
(s) => s.copyWith(spaceFeatures: v))),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('خطا: $e')),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('نمایش نتایج'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,28 @@ class PublicApi {
|
||||
String? q,
|
||||
double? minRating,
|
||||
String? sort,
|
||||
List<String>? themes,
|
||||
List<String>? vibes,
|
||||
List<String>? occasions,
|
||||
List<String>? spaceFeatures,
|
||||
String? noise,
|
||||
String? priceTier,
|
||||
String? size,
|
||||
bool openNow = false,
|
||||
}) async {
|
||||
final params = <String, String>{};
|
||||
if (city != null && city.isNotEmpty) params['city'] = city;
|
||||
if (q != null && q.isNotEmpty) params['q'] = q;
|
||||
if (minRating != null) params['minRating'] = minRating.toString();
|
||||
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
||||
if (themes != null && themes.isNotEmpty) params['themes'] = themes.join(',');
|
||||
if (vibes != null && vibes.isNotEmpty) params['vibes'] = vibes.join(',');
|
||||
if (occasions != null && occasions.isNotEmpty) params['occasions'] = occasions.join(',');
|
||||
if (spaceFeatures != null && spaceFeatures.isNotEmpty) params['spaceFeatures'] = spaceFeatures.join(',');
|
||||
if (noise != null && noise.isNotEmpty) params['noise'] = noise;
|
||||
if (priceTier != null && priceTier.isNotEmpty) params['priceTier'] = priceTier;
|
||||
if (size != null && size.isNotEmpty) params['size'] = size;
|
||||
if (openNow) params['openNow'] = 'true';
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover',
|
||||
queryParameters: params.isEmpty ? null : params,
|
||||
@@ -24,6 +40,43 @@ class PublicApi {
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// Cafés near a coordinate, sorted by distance (for "near me").
|
||||
Future<List<Map<String, dynamic>>> discoverNearby({
|
||||
required double lat,
|
||||
required double lng,
|
||||
String? excludeSlug,
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover/near',
|
||||
queryParameters: {
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
if (excludeSlug != null && excludeSlug.isNotEmpty) 'excludeSlug': excludeSlug,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
final list = res.data?['data'] as List<dynamic>? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// Parse a free-text query into structured discovery hints (themes/vibes/...).
|
||||
Future<Map<String, dynamic>?> nlpParse(String q) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover/nlp-parse',
|
||||
queryParameters: {'q': q},
|
||||
);
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
/// The discovery taxonomy (available themes, vibes, occasions, space features).
|
||||
Future<Map<String, dynamic>?> discoverTaxonomy() async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover-profile/taxonomy',
|
||||
);
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getReviews(String slug, {int page = 1}) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/cafes/$slug/reviews',
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: MeeziApp()));
|
||||
@@ -22,10 +23,9 @@ class MeeziApp extends StatelessWidget {
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6B4F3A)),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: MeeziTheme.light(),
|
||||
darkTheme: MeeziTheme.dark(),
|
||||
themeMode: ThemeMode.light,
|
||||
routerConfig: appRouter,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,7 +198,10 @@ public class AuthController : ControllerBase
|
||||
ExpiresAt: expiresAt,
|
||||
UserId: userId,
|
||||
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
|
||||
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty,
|
||||
// .NET remaps the short "role" claim to ClaimTypes.Role on inbound; read both.
|
||||
Role: User.FindFirstValue(MeeziClaimTypes.Role)
|
||||
?? User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)
|
||||
?? string.Empty,
|
||||
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
|
||||
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
|
||||
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
|
||||
|
||||
@@ -103,4 +103,25 @@ public class BillingController : ControllerBase
|
||||
|
||||
return Ok(new ApiResponse<BillingStatusDto>(true, data));
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("api/billing/queued/{paymentId}")]
|
||||
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||
return Unauthorized();
|
||||
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
||||
|
||||
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
|
||||
if (!ok)
|
||||
{
|
||||
return code == "NOT_FOUND"
|
||||
? NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found.")))
|
||||
: BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { id = paymentId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
gallery,
|
||||
cafe.InstagramHandle,
|
||||
cafe.WebsiteUrl,
|
||||
ToHoursDto(hours))));
|
||||
ToHoursDto(hours),
|
||||
cafe.ShowOnKoja)));
|
||||
}
|
||||
|
||||
// ── PUT (description / social / hours) ───────────────────────────────────
|
||||
@@ -91,6 +92,10 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
if (request.WorkingHours is not null)
|
||||
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
|
||||
|
||||
// Koja (public discovery) listing preference
|
||||
if (request.ShowOnKoja.HasValue)
|
||||
cafe.ShowOnKoja = request.ShowOnKoja.Value;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
||||
@@ -101,7 +106,8 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
gallery,
|
||||
cafe.InstagramHandle,
|
||||
cafe.WebsiteUrl,
|
||||
ToHoursDto(hours))));
|
||||
ToHoursDto(hours),
|
||||
cafe.ShowOnKoja)));
|
||||
}
|
||||
|
||||
// ── POST gallery/upload ───────────────────────────────────────────────────
|
||||
@@ -207,13 +213,15 @@ public record UpdateCafePublicProfileRequest(
|
||||
string? Description,
|
||||
string? InstagramHandle,
|
||||
string? WebsiteUrl,
|
||||
WorkingHoursPublicDto? WorkingHours);
|
||||
WorkingHoursPublicDto? WorkingHours,
|
||||
bool? ShowOnKoja = null);
|
||||
|
||||
public record CafeProfileEditDto(
|
||||
string? Description,
|
||||
IReadOnlyList<string> GalleryUrls,
|
||||
string? InstagramHandle,
|
||||
string? WebsiteUrl,
|
||||
WorkingHoursPublicDto? WorkingHours);
|
||||
WorkingHoursPublicDto? WorkingHours,
|
||||
bool ShowOnKoja);
|
||||
|
||||
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
|
||||
|
||||
@@ -2,7 +2,9 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -12,11 +14,16 @@ public class CafeReviewsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public CafeReviewsController(IReviewService reviews, IValidator<ReplyCafeReviewRequest> replyValidator)
|
||||
public CafeReviewsController(
|
||||
IReviewService reviews,
|
||||
IValidator<ReplyCafeReviewRequest> replyValidator,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_reviews = reviews;
|
||||
_replyValidator = replyValidator;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -41,6 +48,13 @@ public class CafeReviewsController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
// Replying to reviews is a paid feature (Starter+).
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, tier, "review_reply", ct))
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_FEATURE_DISABLED", "Replying to reviews is not included in your plan. Please upgrade.")));
|
||||
|
||||
var validation = await _replyValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
|
||||
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Cafes;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Branding;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -16,11 +18,16 @@ public class CafeSettingsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IValidator<PatchCafeSettingsRequest> _validator;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public CafeSettingsController(AppDbContext db, IValidator<PatchCafeSettingsRequest> validator)
|
||||
public CafeSettingsController(
|
||||
AppDbContext db,
|
||||
IValidator<PatchCafeSettingsRequest> validator,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_db = db;
|
||||
_validator = validator;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -81,7 +88,19 @@ public class CafeSettingsController : CafeApiControllerBase
|
||||
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
|
||||
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
|
||||
if (request.Theme is not null)
|
||||
cafe.ThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
||||
{
|
||||
// Custom menu styling is a paid feature (Starter+). Only block an actual change,
|
||||
// so a normal settings save that re-sends the current theme isn't rejected.
|
||||
var newThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
||||
if (newThemeJson != cafe.ThemeJson)
|
||||
{
|
||||
var styleTier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, styleTier, "custom_menu_styling", ct))
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_FEATURE_DISABLED", "Custom menu styling is not included in your plan. Please upgrade.")));
|
||||
cafe.ThemeJson = newThemeJson;
|
||||
}
|
||||
}
|
||||
if (request.DefaultTaxRate is decimal taxRate)
|
||||
cafe.DefaultTaxRate = taxRate;
|
||||
if (request.AllowBranchTaxOverride is bool allowTax)
|
||||
|
||||
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
// Demo data is a setup helper; Owner or Manager may run it (matches the
|
||||
// dashboard banner, which is shown to both roles).
|
||||
if (EnsureManager(tenant) is { } managerDenied) return managerDenied;
|
||||
|
||||
var result = await _demoSeed.SeedAsync(cafeId, ct);
|
||||
return Ok(new ApiResponse<DemoSeedResult>(true, result));
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Hr;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
@@ -46,6 +47,93 @@ public class HrController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
|
||||
}
|
||||
|
||||
/// <summary>Create a new employee (waiter, cashier, chef, …). Owner/Manager only;
|
||||
/// creating a Manager requires Owner. Optionally sets login credentials in one step.</summary>
|
||||
[HttpPost("employees")]
|
||||
public async Task<IActionResult> CreateEmployee(
|
||||
string cafeId,
|
||||
[FromBody] CreateEmployeeRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
|
||||
IActionResult Invalid(string message, string field) =>
|
||||
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
|
||||
|
||||
var name = request.Name?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Invalid("Name is required.", "Name");
|
||||
|
||||
var phone = request.Phone?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
return Invalid("Phone is required.", "Phone");
|
||||
|
||||
if (!Enum.IsDefined(typeof(EmployeeRole), request.Role))
|
||||
return Invalid("Invalid role.", "Role");
|
||||
// An Owner is created only at café registration, never via this endpoint.
|
||||
if (request.Role == EmployeeRole.Owner)
|
||||
return Invalid("Cannot create an owner here.", "Role");
|
||||
// Only an Owner may add a Manager.
|
||||
if (request.Role == EmployeeRole.Manager && EnsureOwner(tenant) is { } ownerOnly)
|
||||
return ownerOnly;
|
||||
|
||||
// One employee per phone within a café.
|
||||
var phoneTaken = await _db.Employees
|
||||
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null && e.Phone == phone, ct);
|
||||
if (phoneTaken)
|
||||
return Conflict(new ApiResponse<object>(false, null,
|
||||
new ApiError("PHONE_TAKEN", "An employee with this phone already exists.", "Phone")));
|
||||
|
||||
string? branchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId.Trim();
|
||||
if (branchId is not null)
|
||||
{
|
||||
var branchOk = await _db.Branches.AnyAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
if (!branchOk) return Invalid("Invalid branch.", "BranchId");
|
||||
}
|
||||
|
||||
var employee = new Employee
|
||||
{
|
||||
Id = $"emp_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Name = name,
|
||||
Phone = phone,
|
||||
Role = request.Role,
|
||||
BaseSalary = request.BaseSalary ?? 0m,
|
||||
NationalId = string.IsNullOrWhiteSpace(request.NationalId) ? null : request.NationalId.Trim(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// Optional: enable password login in the same step.
|
||||
var wantsCreds = !string.IsNullOrWhiteSpace(request.Username) || !string.IsNullOrWhiteSpace(request.Password);
|
||||
if (wantsCreds)
|
||||
{
|
||||
var username = (request.Username ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return Invalid("Username is required when setting a password.", "Username");
|
||||
if ((request.Password ?? string.Empty).Length < 8)
|
||||
return Invalid("Password must be at least 8 characters.", "Password");
|
||||
|
||||
var usernameTaken = await _db.Employees
|
||||
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null
|
||||
&& e.Username != null && e.Username.ToLower() == username, ct);
|
||||
if (usernameTaken)
|
||||
return Conflict(new ApiResponse<object>(false, null,
|
||||
new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.", "Username")));
|
||||
|
||||
employee.Username = username;
|
||||
employee.PasswordHash = PasswordHasher.Hash(request.Password!);
|
||||
}
|
||||
|
||||
_db.Employees.Add(employee);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var dto = new EmployeeSummaryDto(employee.Id, employee.Name, employee.Phone, employee.Role, employee.BaseSalary);
|
||||
return Ok(new ApiResponse<EmployeeSummaryDto>(true, dto));
|
||||
}
|
||||
|
||||
[HttpGet("employees/{employeeId}")]
|
||||
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -61,6 +61,19 @@ public class InventoryController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<object>(true, updated));
|
||||
}
|
||||
|
||||
[HttpDelete("ingredients/{ingredientId}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
|
||||
}
|
||||
|
||||
[HttpPost("ingredients/{ingredientId}/adjust")]
|
||||
public async Task<IActionResult> Adjust(
|
||||
string cafeId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -81,6 +83,33 @@ public class MediaController : CafeApiControllerBase
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||
|
||||
/// <summary>Media library for this café — previously uploaded files so the UI can
|
||||
/// reuse one instead of re-uploading. Deduplication means each distinct file appears once.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListMedia(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromServices] AppDbContext db,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] string? kind = null,
|
||||
[FromQuery] int limit = 60)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var query = db.MediaAssets.AsNoTracking().Where(m => m.CafeId == cafeId);
|
||||
if (!string.IsNullOrWhiteSpace(kind))
|
||||
query = query.Where(m => m.Kind == kind);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Take(Math.Clamp(limit, 1, 200))
|
||||
.Select(m => new MediaAssetDto(
|
||||
m.Id, m.Url, m.Kind, m.ContentType, m.SizeBytes, m.OriginalFileName, m.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new ApiResponse<List<MediaAssetDto>>(true, items));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> Upload(
|
||||
string cafeId,
|
||||
IFormFile file,
|
||||
@@ -103,3 +132,12 @@ public class MediaController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
public record UploadResultDto(string Url);
|
||||
|
||||
public record MediaAssetDto(
|
||||
string Id,
|
||||
string Url,
|
||||
string Kind,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string? OriginalFileName,
|
||||
DateTime CreatedAt);
|
||||
|
||||
@@ -7,6 +7,7 @@ using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -19,24 +20,27 @@ public class MenuController : CafeApiControllerBase
|
||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
private const string CategoryLimitMessage =
|
||||
"محدودیت دستهبندی پلن رایگان (۳ دسته). برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||
"به سقف دستهبندی منوی پلن شما رسیدید. برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||
private const string ItemLimitMessage =
|
||||
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||
"به سقف آیتم منوی پلن شما رسیدید. برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||
|
||||
public MenuController(
|
||||
IMenuService menuService,
|
||||
IMenuAi3dGenerationService menuAi3d,
|
||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||
IValidator<CreateMenuItemRequest> createItemValidator,
|
||||
AppDbContext db)
|
||||
AppDbContext db,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_menuService = menuService;
|
||||
_menuAi3d = menuAi3d;
|
||||
_createCategoryValidator = createCategoryValidator;
|
||||
_createItemValidator = createItemValidator;
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet("categories")]
|
||||
@@ -59,7 +63,7 @@ public class MenuController : CafeApiControllerBase
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var max = PlanLimits.MaxMenuCategories(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuCategories;
|
||||
if (max != int.MaxValue)
|
||||
{
|
||||
var count = await _db.MenuCategories.CountAsync(
|
||||
@@ -120,7 +124,7 @@ public class MenuController : CafeApiControllerBase
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var max = PlanLimits.MaxMenuItems(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuItems;
|
||||
if (max != int.MaxValue)
|
||||
{
|
||||
var count = await _db.MenuItems.CountAsync(
|
||||
@@ -163,6 +167,15 @@ public class MenuController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("items/{id}")]
|
||||
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
|
||||
[HttpGet("ai-3d/usage")]
|
||||
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using Meezi.API.Services;
|
||||
using Meezi.API.Utils;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -13,13 +14,21 @@ public class ReportsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReportService _reports;
|
||||
private readonly IDailyReportService _dailyReports;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public ReportsController(IReportService reports, IDailyReportService dailyReports)
|
||||
public ReportsController(
|
||||
IReportService reports,
|
||||
IDailyReportService dailyReports,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_reports = reports;
|
||||
_dailyReports = dailyReports;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
private async Task<int> MaxHistoryDaysAsync(ITenantContext tenant, CancellationToken ct) =>
|
||||
(await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxReportHistoryDays;
|
||||
|
||||
[HttpGet("daily")]
|
||||
public async Task<IActionResult> GetDailySnapshot(
|
||||
string cafeId,
|
||||
@@ -37,7 +46,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
|
||||
|
||||
if (EnsureReportDateAllowed(tenant, reportDate) is { } planError) return planError;
|
||||
if (await EnsureReportDateAllowedAsync(tenant, reportDate, ct) is { } planError) return planError;
|
||||
|
||||
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
|
||||
if (snapshot is null)
|
||||
@@ -62,16 +71,16 @@ public class ReportsController : CafeApiControllerBase
|
||||
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
||||
|
||||
var today = IranCalendar.TodayInIran;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||
|
||||
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|
||||
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
|
||||
if (!ReportPlanGate.IsDateInRange(maxDays, startDate, today)
|
||||
|| !ReportPlanGate.IsDateInRange(maxDays, endDate, today))
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "date")));
|
||||
}
|
||||
|
||||
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
|
||||
var clamped = ReportPlanGate.ClampRange(maxDays, startDate, endDate, today);
|
||||
if (clamped is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
|
||||
@@ -91,12 +100,11 @@ public class ReportsController : CafeApiControllerBase
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
|
||||
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||
if (days > maxDays && maxDays != int.MaxValue)
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "days")));
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "days")));
|
||||
}
|
||||
|
||||
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
|
||||
@@ -180,14 +188,14 @@ public class ReportsController : CafeApiControllerBase
|
||||
return DateOnly.TryParse(value, out date);
|
||||
}
|
||||
|
||||
private IActionResult? EnsureReportDateAllowed(ITenantContext tenant, DateOnly date)
|
||||
private async Task<IActionResult?> EnsureReportDateAllowedAsync(ITenantContext tenant, DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||
var today = IranCalendar.TodayInIran;
|
||||
if (ReportPlanGate.IsDateInRange(tier, date, today))
|
||||
if (ReportPlanGate.IsDateInRange(maxDays, date, today))
|
||||
return null;
|
||||
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "date")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,19 @@ public class ReservationsController : CafeApiControllerBase
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateReservationStatusRequest(ReservationStatus Status);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -11,8 +11,13 @@ namespace Meezi.API.Controllers;
|
||||
public class TerminalsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ITerminalRegistryService _terminals;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public TerminalsController(ITerminalRegistryService terminals) => _terminals = terminals;
|
||||
public TerminalsController(ITerminalRegistryService terminals, IPlatformCatalogService catalog)
|
||||
{
|
||||
_terminals = terminals;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register(
|
||||
@@ -35,7 +40,7 @@ public class TerminalsController : CafeApiControllerBase
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var list = await _terminals.ListAsync(cafeId, ct);
|
||||
var max = PlanLimits.MaxTerminals(tenant.PlanTier ?? PlanTier.Free);
|
||||
var max = (await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxTerminals;
|
||||
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,9 @@ public static class ServiceCollectionExtensions
|
||||
app.UseMeeziSecurity();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<Middleware.TenantMiddleware>();
|
||||
// After tenant context (keys are scoped per café), before plan-limit + controllers
|
||||
// so a replayed write short-circuits without re-consuming limits or re-executing.
|
||||
app.UseMiddleware<Middleware.IdempotencyMiddleware>();
|
||||
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -242,6 +245,11 @@ public static class ServiceCollectionExtensions
|
||||
"branch-permanent-delete",
|
||||
job => job.ExecuteAsync(),
|
||||
Cron.Hourly);
|
||||
|
||||
RecurringJob.AddOrUpdate<IdempotencyCleanupJob>(
|
||||
"idempotency-cleanup",
|
||||
job => job.ExecuteAsync(),
|
||||
Cron.Daily(4));
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Purges old idempotency records. Keys only need to outlive realistic offline
|
||||
/// gaps and client retries, so a short retention keeps the table small.
|
||||
/// </summary>
|
||||
public class IdempotencyCleanupJob
|
||||
{
|
||||
private static readonly TimeSpan Retention = TimeSpan.FromDays(7);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<IdempotencyCleanupJob> _logger;
|
||||
|
||||
public IdempotencyCleanupJob(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<IdempotencyCleanupJob> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var cutoff = DateTime.UtcNow - Retention;
|
||||
var removed = await db.IdempotencyRecords
|
||||
.Where(r => r.CreatedAt < cutoff)
|
||||
.ExecuteDeleteAsync();
|
||||
if (removed > 0)
|
||||
_logger.LogInformation("Purged {Count} idempotency records older than {Days}d", removed, Retention.TotalDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Makes mutating requests safe to retry. A client (e.g. the offline outbox)
|
||||
/// attaches an <c>Idempotency-Key</c> header; if the same key is seen again, the
|
||||
/// original response is replayed instead of executing the write twice.
|
||||
///
|
||||
/// Bookkeeping runs in isolated DI scopes so it never mixes with the controller's
|
||||
/// own DbContext unit of work. Opt-in via header → non-idempotent and binary/file
|
||||
/// endpoints are unaffected unless the client explicitly sends a key.
|
||||
/// </summary>
|
||||
public class IdempotencyMiddleware
|
||||
{
|
||||
private const string HeaderName = "Idempotency-Key";
|
||||
private const int MaxKeyLength = 200;
|
||||
private const int MaxStoredBodyBytes = 256 * 1024;
|
||||
/// <summary>An InProgress record older than this is assumed crashed mid-flight and re-run.</summary>
|
||||
private static readonly TimeSpan StaleInProgress = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<IdempotencyMiddleware> _logger;
|
||||
|
||||
public IdempotencyMiddleware(RequestDelegate next, ILogger<IdempotencyMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ITenantContext tenant, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
var method = context.Request.Method;
|
||||
var isMutating = HttpMethods.IsPost(method) || HttpMethods.IsPut(method)
|
||||
|| HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method);
|
||||
|
||||
if (!isMutating || !context.Request.Headers.TryGetValue(HeaderName, out var headerValues))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var key = headerValues.ToString();
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Length > MaxKeyLength)
|
||||
{
|
||||
// Unusable key — behave as if it wasn't sent rather than reject the write.
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var scope = string.IsNullOrEmpty(tenant.CafeId) ? "global" : tenant.CafeId;
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
|
||||
// 1) Look for an existing record for this (tenant, key).
|
||||
await using (var lookupScope = scopeFactory.CreateAsyncScope())
|
||||
{
|
||||
var db = lookupScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var existing = await db.IdempotencyRecords.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Scope == scope && r.Key == key, context.RequestAborted);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.Status == IdempotencyStatus.Completed)
|
||||
{
|
||||
await ReplayAsync(context, existing);
|
||||
return;
|
||||
}
|
||||
if (DateTime.UtcNow - existing.CreatedAt < StaleInProgress)
|
||||
{
|
||||
await WriteConflictAsync(context); // genuine concurrent duplicate
|
||||
return;
|
||||
}
|
||||
// Stale reservation (process likely crashed mid-flight) — drop and re-run.
|
||||
_logger.LogWarning("Recovering stale idempotency reservation {Key} for scope {Scope}", key, scope);
|
||||
var stale = await db.IdempotencyRecords
|
||||
.FirstOrDefaultAsync(r => r.Id == existing.Id, context.RequestAborted);
|
||||
if (stale is not null)
|
||||
{
|
||||
db.IdempotencyRecords.Remove(stale);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Reserve the key. The unique (Scope, Key) index serializes racing first requests.
|
||||
var record = new IdempotencyRecord
|
||||
{
|
||||
Scope = scope,
|
||||
Key = key,
|
||||
Method = method,
|
||||
Path = path,
|
||||
Status = IdempotencyStatus.InProgress,
|
||||
};
|
||||
try
|
||||
{
|
||||
await using var reserveScope = scopeFactory.CreateAsyncScope();
|
||||
var db = reserveScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.IdempotencyRecords.Add(record);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await WriteConflictAsync(context); // another request won the reservation race
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Run the real request, capturing its response.
|
||||
var originalBody = context.Response.Body;
|
||||
await using var buffer = new MemoryStream();
|
||||
context.Response.Body = buffer;
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
await DeleteAsync(scopeFactory, record.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
var statusCode = context.Response.StatusCode;
|
||||
buffer.Position = 0;
|
||||
var bytes = buffer.ToArray();
|
||||
context.Response.Body = originalBody;
|
||||
if (bytes.Length > 0)
|
||||
await originalBody.WriteAsync(bytes, context.RequestAborted);
|
||||
|
||||
// 4) Persist the result so retries replay it — except 5xx, which is transient and
|
||||
// released so the client can retry the same key.
|
||||
if (statusCode is >= 200 and < 500)
|
||||
{
|
||||
var storedBody = bytes.Length is > 0 and <= MaxStoredBodyBytes
|
||||
? Encoding.UTF8.GetString(bytes)
|
||||
: null;
|
||||
await CompleteAsync(scopeFactory, record.Id, statusCode, storedBody);
|
||||
}
|
||||
else
|
||||
{
|
||||
await DeleteAsync(scopeFactory, record.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReplayAsync(HttpContext context, IdempotencyRecord record)
|
||||
{
|
||||
context.Response.StatusCode = record.ResponseStatusCode;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
context.Response.Headers["Idempotent-Replay"] = "true";
|
||||
if (!string.IsNullOrEmpty(record.ResponseBody))
|
||||
await context.Response.WriteAsync(record.ResponseBody);
|
||||
}
|
||||
|
||||
private static async Task WriteConflictAsync(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await context.Response.WriteAsync(
|
||||
"{\"success\":false,\"data\":null,\"error\":{\"code\":\"IDEMPOTENCY_IN_PROGRESS\",\"message\":\"A request with this key is still being processed.\"}}");
|
||||
}
|
||||
|
||||
private static async Task CompleteAsync(IServiceScopeFactory f, string id, int status, string? body)
|
||||
{
|
||||
await using var s = f.CreateAsyncScope();
|
||||
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (rec is null) return;
|
||||
rec.Status = IdempotencyStatus.Completed;
|
||||
rec.ResponseStatusCode = status;
|
||||
rec.ResponseBody = body;
|
||||
rec.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task DeleteAsync(IServiceScopeFactory f, string id)
|
||||
{
|
||||
await using var s = f.CreateAsyncScope();
|
||||
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (rec is null) return;
|
||||
db.IdempotencyRecords.Remove(rec);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,12 @@ public class TenantMiddleware
|
||||
{
|
||||
scopedMerchant.CafeId = cafeId;
|
||||
|
||||
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value;
|
||||
// .NET's JWT handler remaps the short "role" claim to ClaimTypes.Role
|
||||
// on inbound, so FindFirst("role") returns null and tenant.Role would
|
||||
// stay null — making EnsureManager/EnsureOwner reject even a real owner.
|
||||
// Read both the raw claim and the mapped one.
|
||||
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value
|
||||
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
|
||||
if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role))
|
||||
scopedMerchant.Role = role;
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ public record BillingStatusDto(
|
||||
int MenuAi3dUsedThisMonth,
|
||||
int MenuAi3dMonthlyLimit,
|
||||
bool DiscoverProfileEnabled,
|
||||
bool IsPlanExpired);
|
||||
bool IsPlanExpired,
|
||||
IReadOnlyList<QueuedPlanDto> QueuedPlans);
|
||||
|
||||
public record QueuedPlanDto(
|
||||
string PaymentId,
|
||||
PlanTier PlanTier,
|
||||
int Months,
|
||||
DateTime EffectiveFrom,
|
||||
DateTime EffectiveTo,
|
||||
decimal AmountToman);
|
||||
|
||||
public record BillingVerifyResult(bool Success, string RedirectUrl);
|
||||
|
||||
@@ -62,3 +62,15 @@ public record TodayShiftDto(ShiftType ShiftType, string Label);
|
||||
|
||||
/// <summary>Set or update username/password credentials for an employee.</summary>
|
||||
public record SetEmployeeCredentialsRequest(string Username, string Password);
|
||||
|
||||
/// <summary>Create a new employee. Owner/Manager only; Manager role requires Owner.
|
||||
/// Username+Password are optional and, when supplied, enable dashboard/POS login.</summary>
|
||||
public record CreateEmployeeRequest(
|
||||
string Name,
|
||||
string Phone,
|
||||
EmployeeRole Role,
|
||||
string? BranchId = null,
|
||||
decimal? BaseSalary = null,
|
||||
string? NationalId = null,
|
||||
string? Username = null,
|
||||
string? Password = null);
|
||||
|
||||
@@ -80,4 +80,5 @@ public record LiveOrderDto(
|
||||
OrderType OrderType,
|
||||
decimal Total,
|
||||
DateTime CreatedAt,
|
||||
IReadOnlyList<OrderItemDto> Items);
|
||||
IReadOnlyList<OrderItemDto> Items,
|
||||
OrderSource Source);
|
||||
|
||||
@@ -107,7 +107,8 @@ public record PublicMenuDto(
|
||||
string CafeName,
|
||||
string Slug,
|
||||
CafeThemeDto Theme,
|
||||
IReadOnlyList<PublicMenuCategoryDto> Categories);
|
||||
IReadOnlyList<PublicMenuCategoryDto> Categories,
|
||||
bool ShowWatermark);
|
||||
|
||||
public record GuestCreateOrderRequest(
|
||||
OrderType OrderType,
|
||||
|
||||
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
|
||||
if (employee?.Cafe is null)
|
||||
return (false, null, "NOT_FOUND", "User no longer exists.");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
// Note: we intentionally do NOT revoke the presented refresh token here.
|
||||
// It is reused (with a slid TTL) so concurrent refreshes from multiple
|
||||
// tabs/devices stay valid instead of racing each other into a logout.
|
||||
|
||||
var allMemberships = await _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
@@ -265,7 +267,9 @@ public class AuthService : IAuthService
|
||||
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||
.ToList();
|
||||
|
||||
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken);
|
||||
var tokens = await IssueTokensAsync(
|
||||
employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken,
|
||||
existingRefreshToken: request.RefreshToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
@@ -510,12 +514,18 @@ public class AuthService : IAuthService
|
||||
Core.Entities.Cafe cafe,
|
||||
List<CafeMembershipDto>? memberships,
|
||||
string? requestedBranchId,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
string? existingRefreshToken = null)
|
||||
{
|
||||
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
|
||||
|
||||
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
|
||||
var refreshToken = _jwtTokenService.CreateRefreshToken();
|
||||
// On refresh, reuse the caller's refresh token (and slide its TTL below) instead
|
||||
// of minting a new one. A café often runs POS + KDS + queue display at once; if
|
||||
// refresh rotated the token, the first refresh would revoke it and every other
|
||||
// concurrent refresh would get INVALID_TOKEN → forced logout → OTP storm.
|
||||
// Mint a fresh token only on a real login (existingRefreshToken == null).
|
||||
var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken();
|
||||
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
|
||||
|
||||
await _refreshTokenStore.StoreAsync(
|
||||
|
||||
@@ -35,6 +35,11 @@ public interface IBillingService
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
|
||||
string cafeId,
|
||||
string paymentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BillingService : IBillingService
|
||||
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
|
||||
return new BillingVerifyResult(false, failUrl);
|
||||
}
|
||||
|
||||
payment.Status = SubscriptionPaymentStatus.Completed;
|
||||
payment.RefId = verify.RefId;
|
||||
|
||||
var cafe = payment.Cafe;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Where does the current paid coverage end? = the latest of the active plan's expiry
|
||||
// and the furthest-out already-queued period. A new purchase is appended to that.
|
||||
var coverageEnd = await ComputeCoverageEndAsync(cafe, payment.Id, now, cancellationToken);
|
||||
|
||||
payment.EffectiveFrom = coverageEnd;
|
||||
payment.EffectiveTo = coverageEnd.AddMonths(payment.Months);
|
||||
|
||||
var queued = coverageEnd > now;
|
||||
if (queued)
|
||||
{
|
||||
// The owner already has active/queued coverage → book this one after it.
|
||||
payment.Status = SubscriptionPaymentStatus.Scheduled;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No active coverage → activate immediately.
|
||||
payment.Status = SubscriptionPaymentStatus.Completed;
|
||||
cafe.PlanTier = payment.PlanTier;
|
||||
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
|
||||
? cafe.PlanExpiresAt.Value
|
||||
: DateTime.UtcNow;
|
||||
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months);
|
||||
cafe.PlanExpiresAt = payment.EffectiveTo;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken);
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
}
|
||||
|
||||
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
|
||||
/// and the furthest-out scheduled (queued) period. Returns <paramref name="now"/> if neither
|
||||
/// extends past now (i.e. nothing active/queued).</summary>
|
||||
private async Task<DateTime> ComputeCoverageEndAsync(
|
||||
Cafe cafe, string? excludePaymentId, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var end = now;
|
||||
if (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > end)
|
||||
end = cafe.PlanExpiresAt.Value;
|
||||
|
||||
var lastScheduledEnd = await _db.SubscriptionPayments
|
||||
.Where(p => p.CafeId == cafe.Id
|
||||
&& p.Status == SubscriptionPaymentStatus.Scheduled
|
||||
&& (excludePaymentId == null || p.Id != excludePaymentId)
|
||||
&& p.EffectiveTo != null)
|
||||
.OrderByDescending(p => p.EffectiveTo)
|
||||
.Select(p => p.EffectiveTo)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (lastScheduledEnd.HasValue && lastScheduledEnd.Value > end)
|
||||
end = lastScheduledEnd.Value;
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
/// <summary>When the active plan has lapsed, promote due queued periods to active.
|
||||
/// Loops so a fully-elapsed short queued period doesn't strand the next one.</summary>
|
||||
private async Task PromoteDueScheduledAsync(string cafeId, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||
if (cafe is null) return;
|
||||
|
||||
var changed = false;
|
||||
while (!(cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now))
|
||||
{
|
||||
var next = await _db.SubscriptionPayments
|
||||
.Where(p => p.CafeId == cafeId
|
||||
&& p.Status == SubscriptionPaymentStatus.Scheduled
|
||||
&& p.EffectiveFrom != null && p.EffectiveFrom <= now)
|
||||
.OrderBy(p => p.EffectiveFrom)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (next is null) break;
|
||||
|
||||
cafe.PlanTier = next.PlanTier;
|
||||
cafe.PlanExpiresAt = next.EffectiveTo;
|
||||
next.Status = SubscriptionPaymentStatus.Completed;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
|
||||
string cafeId,
|
||||
string paymentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payment = await _db.SubscriptionPayments
|
||||
.FirstOrDefaultAsync(p => p.Id == paymentId && p.CafeId == cafeId, cancellationToken);
|
||||
if (payment is null)
|
||||
return (false, "NOT_FOUND", "Subscription not found.");
|
||||
|
||||
// Only a queued (not-yet-started) subscription can be cancelled. The active prepaid
|
||||
// plan keeps running until its paid time ends.
|
||||
if (payment.Status != SubscriptionPaymentStatus.Scheduled)
|
||||
return (false, "NOT_CANCELLABLE", "Only a queued subscription can be cancelled.");
|
||||
|
||||
payment.Status = SubscriptionPaymentStatus.Cancelled;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Re-pack the remaining queue so later periods slide earlier to fill the gap.
|
||||
await RecomputeQueueAsync(cafeId, cancellationToken);
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
/// <summary>Re-sequences the remaining queued periods contiguously after the active plan
|
||||
/// (purchase order preserved), so cancelling one in the middle doesn't leave a gap.</summary>
|
||||
private async Task RecomputeQueueAsync(string cafeId, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||
if (cafe is null) return;
|
||||
|
||||
var anchor = (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now)
|
||||
? cafe.PlanExpiresAt.Value
|
||||
: now;
|
||||
|
||||
var scheduled = await _db.SubscriptionPayments
|
||||
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled)
|
||||
.OrderBy(p => p.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var s in scheduled)
|
||||
{
|
||||
s.EffectiveFrom = anchor;
|
||||
s.EffectiveTo = anchor.AddMonths(s.Months);
|
||||
anchor = s.EffectiveTo.Value;
|
||||
}
|
||||
|
||||
if (scheduled.Count > 0) await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<BillingStatusDto?> GetStatusAsync(
|
||||
string cafeId,
|
||||
PlanTier currentTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Lazily activate any queued plan whose start date has passed before reading status.
|
||||
await PromoteDueScheduledAsync(cafeId, cancellationToken);
|
||||
|
||||
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var queuedPlans = await _db.SubscriptionPayments.AsNoTracking()
|
||||
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled
|
||||
&& p.EffectiveFrom != null && p.EffectiveTo != null)
|
||||
.OrderBy(p => p.EffectiveFrom)
|
||||
.Select(p => new QueuedPlanDto(
|
||||
p.Id, p.PlanTier, p.Months, p.EffectiveFrom!.Value, p.EffectiveTo!.Value, p.AmountToman))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var ordersToday = await _db.Orders.CountAsync(
|
||||
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
|
||||
@@ -278,12 +413,14 @@ public class BillingService : IBillingService
|
||||
ai3dUsedCount,
|
||||
ai3dLimit,
|
||||
discoverProfile,
|
||||
isExpired);
|
||||
isExpired,
|
||||
queuedPlans);
|
||||
}
|
||||
|
||||
private async Task TrySendConfirmationSmsAsync(
|
||||
Cafe cafe,
|
||||
SubscriptionPayment payment,
|
||||
bool queued,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerPhone = await _db.Employees
|
||||
@@ -293,8 +430,9 @@ public class BillingService : IBillingService
|
||||
|
||||
if (string.IsNullOrEmpty(ownerPhone)) return;
|
||||
|
||||
var message =
|
||||
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||
var message = queued
|
||||
? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز میشود. مبلغ: {payment.AmountToman:N0} ت"
|
||||
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||
try
|
||||
{
|
||||
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
|
||||
|
||||
@@ -345,5 +345,6 @@ public class DeliveryOrderProcessor : IDeliveryOrderProcessor
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList());
|
||||
i.VoidedAt)).ToList(),
|
||||
o.Source);
|
||||
}
|
||||
|
||||
@@ -130,7 +130,10 @@ public class DemoSeedService : IDemoSeedService
|
||||
decimal qty, decimal reorder, decimal cost, decimal par) =>
|
||||
new()
|
||||
{
|
||||
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36],
|
||||
// No [..36] truncation: Id is a text column, and truncating to 36 chars
|
||||
// cuts off the unique guid for real (32-char) café ids → every row gets
|
||||
// the same id → PK collision → 500. Keep the full unique id.
|
||||
Id = $"{cafeId}_ing_{Guid.NewGuid():N}",
|
||||
CafeId = cafeId,
|
||||
Name = name,
|
||||
Unit = unit,
|
||||
@@ -160,7 +163,9 @@ public class DemoSeedService : IDemoSeedService
|
||||
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
|
||||
new()
|
||||
{
|
||||
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36],
|
||||
// No [..36] truncation (see Ingredient above): truncating cuts the guid
|
||||
// for real 32-char café ids → identical ids → PK collision → 500.
|
||||
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}",
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Number = number,
|
||||
|
||||
@@ -89,6 +89,7 @@ public interface IInventoryService
|
||||
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
|
||||
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
|
||||
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
|
||||
Task<IngredientDto?> AdjustAsync(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
@@ -205,6 +206,18 @@ public class InventoryService : IInventoryService
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
|
||||
if (entity is null) return false;
|
||||
|
||||
// Soft delete: Ingredient has a global DeletedAt query filter, so it (and its
|
||||
// recipe lines / stock movements) drop out of every query without FK trouble.
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IngredientDto?> AdjustAsync(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IMediaStorageService
|
||||
@@ -37,11 +42,16 @@ public class MediaStorageService : IMediaStorageService
|
||||
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<MediaStorageService> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public MediaStorageService(IWebHostEnvironment env, ILogger<MediaStorageService> logger)
|
||||
public MediaStorageService(
|
||||
IWebHostEnvironment env,
|
||||
ILogger<MediaStorageService> logger,
|
||||
IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
@@ -100,16 +110,29 @@ public class MediaStorageService : IMediaStorageService
|
||||
|| Model3dMime.Contains(file.ContentType);
|
||||
if (!isGlb) return null;
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
await file.CopyToAsync(buffer, cancellationToken);
|
||||
var bytes = buffer.ToArray();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation("Dedup hit for 3D model (cafe {CafeId}); reusing existing file", cafeId);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
||||
Directory.CreateDirectory(dir);
|
||||
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
|
||||
var path = Path.Combine(dir, savedName);
|
||||
|
||||
await using var stream = File.Create(path);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||
|
||||
var url = $"/uploads/{cafeId}/{savedName}";
|
||||
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, "menu_3d", file.FileName, cancellationToken);
|
||||
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
|
||||
return $"/uploads/{cafeId}/{savedName}";
|
||||
return url;
|
||||
}
|
||||
|
||||
private async Task<string?> SaveAsync(
|
||||
@@ -123,6 +146,20 @@ public class MediaStorageService : IMediaStorageService
|
||||
if (file.Length == 0 || file.Length > maxBytes) return null;
|
||||
if (!allowedMime.Contains(file.ContentType)) return null;
|
||||
|
||||
// Buffer once so we can hash the content and (if new) write it.
|
||||
await using var buffer = new MemoryStream();
|
||||
await file.CopyToAsync(buffer, cancellationToken);
|
||||
var bytes = buffer.ToArray();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
// Dedup: an identical file already stored for this scope is reused as-is.
|
||||
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation("Dedup hit for {Prefix} (cafe {CafeId}); reusing existing file", prefix, cafeId);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var ext = file.ContentType.ToLowerInvariant() switch
|
||||
{
|
||||
"image/png" => ".png",
|
||||
@@ -138,10 +175,61 @@ public class MediaStorageService : IMediaStorageService
|
||||
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
|
||||
var path = Path.Combine(dir, fileName);
|
||||
|
||||
await using var stream = File.Create(path);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||
|
||||
var url = $"/uploads/{cafeId}/{fileName}";
|
||||
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, prefix, file.FileName, cancellationToken);
|
||||
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
|
||||
return $"/uploads/{cafeId}/{fileName}";
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── Deduplication helpers ────────────────────────────────────────────────
|
||||
// MediaStorageService is a singleton; resolve a scoped DbContext per call.
|
||||
|
||||
private async Task<string?> FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
return await db.MediaAssets.AsNoTracking()
|
||||
.Where(m => m.CafeId == cafeId && m.ContentHash == hash)
|
||||
.Select(m => m.Url)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never let a dedup-lookup failure block an upload.
|
||||
_logger.LogWarning(ex, "Media dedup lookup failed; proceeding with a fresh upload");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordAsync(
|
||||
string? cafeId, string hash, long size, string contentType,
|
||||
string url, string kind, string? originalName, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.MediaAssets.Add(new MediaAsset
|
||||
{
|
||||
CafeId = cafeId,
|
||||
ContentHash = hash,
|
||||
SizeBytes = size,
|
||||
ContentType = contentType,
|
||||
Url = url,
|
||||
Kind = kind,
|
||||
OriginalFileName = originalName,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The file is already written; a missing dedup record only means a
|
||||
// future identical upload won't be de-duplicated. Don't fail the upload.
|
||||
_logger.LogWarning(ex, "Failed to record media asset for cafe {CafeId}", cafeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ public class MenuAi3dGenerationService : IMenuAi3dGenerationService
|
||||
{
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken))
|
||||
return 0;
|
||||
return PlanLimits.MaxMenuAi3dPerMonth(planTier);
|
||||
return (await _catalog.GetLimitsAsync(planTier, cancellationToken)).MaxMenuAi3dPerMonth;
|
||||
}
|
||||
|
||||
private static string UsageKey(string cafeId) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IMenuService
|
||||
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class MenuService : IMenuService
|
||||
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
|
||||
return ToItemDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalText(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
|
||||
@@ -1221,5 +1221,6 @@ public class OrderService : IOrderService
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList());
|
||||
i.VoidedAt)).ToList(),
|
||||
o.Source);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,21 @@ public class PlanLimitChecker : IPlanLimitChecker
|
||||
return (false, "PLAN_LIMIT_REACHED", "Branch limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
|
||||
var tablesPath = $"/api/cafes/{cafeId}/tables";
|
||||
if (path.StartsWith(tablesPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(path.Equals(tablesPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{tablesPath}/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var limitsTables = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxTables = limitsTables.MaxTables;
|
||||
if (maxTables != int.MaxValue)
|
||||
{
|
||||
var tableCount = await _db.Tables.CountAsync(t => t.CafeId == cafeId, cancellationToken);
|
||||
if (tableCount >= maxTables)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Table limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
}
|
||||
|
||||
var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign";
|
||||
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -53,6 +53,7 @@ public class PublicService : IPublicService
|
||||
private readonly IBranchIdentityService _identity;
|
||||
private readonly IAbuseProtectionService _abuse;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
|
||||
|
||||
public PublicService(
|
||||
AppDbContext db,
|
||||
@@ -62,7 +63,8 @@ public class PublicService : IPublicService
|
||||
IBranchMenuService branchMenu,
|
||||
IBranchIdentityService identity,
|
||||
IAbuseProtectionService abuse,
|
||||
IHttpContextAccessor http)
|
||||
IHttpContextAccessor http,
|
||||
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
|
||||
{
|
||||
_db = db;
|
||||
_orders = orders;
|
||||
@@ -72,8 +74,13 @@ public class PublicService : IPublicService
|
||||
_identity = identity;
|
||||
_abuse = abuse;
|
||||
_http = http;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
/// <summary>Free menus show a Meezi watermark; the `watermark_removed` feature (paid) hides it.</summary>
|
||||
private async Task<bool> ShowWatermarkAsync(Cafe cafe, CancellationToken ct) =>
|
||||
!await _catalog.IsFeatureEnabledForCafeAsync(cafe.Id, cafe.PlanTier, "watermark_removed", ct);
|
||||
|
||||
public Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
|
||||
DiscoverFilterParams filters,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
@@ -190,7 +197,8 @@ public class PublicService : IPublicService
|
||||
.Where(c => c.Items.Count > 0)
|
||||
.ToList();
|
||||
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped,
|
||||
await ShowWatermarkAsync(cafe, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync(
|
||||
@@ -357,7 +365,8 @@ public class PublicService : IPublicService
|
||||
.OrderBy(c => categoryById.GetValueOrDefault(c.Id)?.SortOrder ?? 0)
|
||||
.ToList();
|
||||
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped,
|
||||
await ShowWatermarkAsync(cafe, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Report-history window checks. Takes the (admin-editable) max-history days
|
||||
/// directly so the limit comes from the plan catalog, not a hardcoded tier table.
|
||||
/// </summary>
|
||||
public static class ReportPlanGate
|
||||
{
|
||||
public static bool IsDateInRange(PlanTier tier, DateOnly date, DateOnly todayIran)
|
||||
public static bool IsDateInRange(int maxDays, DateOnly date, DateOnly todayIran)
|
||||
{
|
||||
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
|
||||
if (maxDays == int.MaxValue)
|
||||
return date <= todayIran;
|
||||
|
||||
@@ -16,16 +16,15 @@ public static class ReportPlanGate
|
||||
}
|
||||
|
||||
public static (DateOnly From, DateOnly To)? ClampRange(
|
||||
PlanTier tier,
|
||||
int maxDays,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
DateOnly todayIran)
|
||||
{
|
||||
if (from > to) return null;
|
||||
if (!IsDateInRange(tier, to, todayIran) || !IsDateInRange(tier, from, todayIran))
|
||||
if (!IsDateInRange(maxDays, to, todayIran) || !IsDateInRange(maxDays, from, todayIran))
|
||||
return null;
|
||||
|
||||
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
|
||||
if (maxDays == int.MaxValue)
|
||||
return (from, to);
|
||||
|
||||
@@ -36,16 +35,8 @@ public static class ReportPlanGate
|
||||
return (clampedFrom, clampedTo);
|
||||
}
|
||||
|
||||
public static string LimitMessage(PlanTier tier)
|
||||
{
|
||||
var days = PlanLimits.MaxReportHistoryDays(tier);
|
||||
return tier switch
|
||||
{
|
||||
PlanTier.Free =>
|
||||
"Daily reports on the Free plan are limited to today and the previous 7 days. Upgrade to Pro for 90 days of history.",
|
||||
PlanTier.Pro =>
|
||||
"Daily reports on the Pro plan are limited to the last 90 days. Upgrade to Business for unlimited history.",
|
||||
_ => "Report date is outside your plan range."
|
||||
};
|
||||
}
|
||||
public static string LimitMessage(int maxDays) =>
|
||||
maxDays == int.MaxValue
|
||||
? "Report date is outside the allowed range."
|
||||
: $"Daily reports on your plan are limited to the last {maxDays} days. Upgrade for more history.";
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ public interface IReservationService
|
||||
string reservationId,
|
||||
ReservationStatus status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
string cafeId,
|
||||
string reservationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ReservationService : IReservationService
|
||||
@@ -118,6 +123,25 @@ public class ReservationService : IReservationService
|
||||
return Map(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(
|
||||
string cafeId,
|
||||
string reservationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.TableReservations
|
||||
.FirstOrDefaultAsync(r => r.Id == reservationId && r.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
// Soft delete: TableReservation has a global DeletedAt query filter.
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.TableId))
|
||||
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static ReservationDto Map(TableReservation r) => new(
|
||||
r.Id,
|
||||
r.CafeId,
|
||||
|
||||
@@ -62,7 +62,7 @@ public class ReviewService : IReviewService
|
||||
DiscoverFilterParams filters,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null);
|
||||
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null && c.ShowOnKoja);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filters.City))
|
||||
query = query.Where(c => c.City != null && c.City.Contains(filters.City));
|
||||
|
||||
@@ -183,5 +183,6 @@ public class SnappfoodWebhookService : ISnappfoodWebhookService
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList());
|
||||
i.VoidedAt)).ToList(),
|
||||
o.Source);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,15 @@ public class TerminalRegistryService : ITerminalRegistryService
|
||||
{
|
||||
private static readonly TimeSpan TerminalTtl = TimeSpan.FromDays(90);
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
|
||||
|
||||
public TerminalRegistryService(IConnectionMultiplexer redis) => _redis = redis;
|
||||
public TerminalRegistryService(
|
||||
IConnectionMultiplexer redis,
|
||||
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
|
||||
{
|
||||
_redis = redis;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
public async Task<(bool Allowed, string? ErrorCode, string? Message)> RegisterAsync(
|
||||
string cafeId,
|
||||
@@ -38,7 +45,7 @@ public class TerminalRegistryService : ITerminalRegistryService
|
||||
terminalId = terminalId.Trim();
|
||||
var db = _redis.GetDatabase();
|
||||
var setKey = $"terminals:{cafeId}";
|
||||
var max = PlanLimits.MaxTerminals(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxTerminals;
|
||||
|
||||
if (max == int.MaxValue)
|
||||
{
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"Key": "meezi-dev-secret-key-min-32-chars!!",
|
||||
"Issuer": "meezi",
|
||||
"Audience": "meezi",
|
||||
"AccessTokenExpiryDays": 7,
|
||||
"RefreshTokenExpiryDays": 30
|
||||
"AccessTokenExpiryDays": 30,
|
||||
"RefreshTokenExpiryDays": 365
|
||||
},
|
||||
"App": {
|
||||
"PublicBaseUrl": "https://localhost:7208",
|
||||
|
||||
@@ -2,30 +2,53 @@ using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.Core.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Code-level DEFAULTS for per-plan numeric limits. These are the fallback /
|
||||
/// seed values; the source of truth at runtime is the admin-editable
|
||||
/// PlatformPlanDefinition (LimitsJson) read via IPlatformCatalogService.
|
||||
/// Gating uses explicit tier sets, never `tier >= X`, so the appended Starter
|
||||
/// tier (enum value 4) is handled correctly.
|
||||
/// </summary>
|
||||
public static class PlanLimits
|
||||
{
|
||||
private static bool IsPaid(PlanTier t) => t is not PlanTier.Free;
|
||||
private static bool IsProPlus(PlanTier t) =>
|
||||
t is PlanTier.Pro or PlanTier.Business or PlanTier.Enterprise;
|
||||
private static bool IsBusinessPlus(PlanTier t) =>
|
||||
t is PlanTier.Business or PlanTier.Enterprise;
|
||||
|
||||
public static int MaxOrdersPerDay(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 50,
|
||||
PlanTier.Free => 30,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
/// <summary>Maximum tables a café may define.</summary>
|
||||
public static int MaxTables(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 6,
|
||||
PlanTier.Starter => 15,
|
||||
PlanTier.Pro => 40,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
public static int MaxTerminals(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 1,
|
||||
PlanTier.Starter => 2,
|
||||
PlanTier.Pro => 3,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
public static int MaxCustomers(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 50,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
public static int MaxCustomers(PlanTier tier) => int.MaxValue; // CRM module gated by CanAccessCrm
|
||||
|
||||
/// <summary>Monthly bundled SMS. The product direction is pay-as-you-go credits for
|
||||
/// all tiers, but until the credit-purchase system ships we keep the existing bundled
|
||||
/// quotas so paying cafés don't lose SMS. (Switch to 0 + credits in the SMS stage.)</summary>
|
||||
public static int MaxSmsPerMonth(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 0,
|
||||
PlanTier.Starter => 0,
|
||||
PlanTier.Pro => 50,
|
||||
PlanTier.Business => 200,
|
||||
_ => int.MaxValue
|
||||
@@ -34,8 +57,8 @@ public static class PlanLimits
|
||||
public static int MaxBranches(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 1,
|
||||
PlanTier.Starter => 1,
|
||||
PlanTier.Pro => 3,
|
||||
PlanTier.Business => int.MaxValue,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
@@ -43,35 +66,27 @@ public static class PlanLimits
|
||||
public static int MaxReportHistoryDays(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 8,
|
||||
PlanTier.Starter => 30,
|
||||
PlanTier.Pro => 90,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
/// <summary>AI image-to-3D generations per calendar month (UTC).</summary>
|
||||
public static int MaxMenuAi3dPerMonth(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Business => 100,
|
||||
PlanTier.Enterprise => 100,
|
||||
_ => 0
|
||||
};
|
||||
/// <summary>AI image-to-3D generations per calendar month (UTC). Business+ only.</summary>
|
||||
public static int MaxMenuAi3dPerMonth(PlanTier tier) => IsBusinessPlus(tier) ? 100 : 0;
|
||||
|
||||
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
|
||||
/// <summary>Maximum active menu categories. Free is capped at 10; paid tiers unlimited.</summary>
|
||||
public static int MaxMenuCategories(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 3,
|
||||
PlanTier.Free => 10,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
|
||||
public static int MaxMenuItems(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 30,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
/// <summary>Menu items are unlimited on every tier (Free can fully build the menu).</summary>
|
||||
public static int MaxMenuItems(PlanTier tier) => int.MaxValue;
|
||||
|
||||
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
|
||||
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
|
||||
/// <summary>CRM (customers, loyalty) — Pro and above.</summary>
|
||||
public static bool CanAccessCrm(PlanTier tier) => IsProPlus(tier);
|
||||
|
||||
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
|
||||
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
|
||||
/// <summary>Statistics / analytics dashboards — Pro and above.</summary>
|
||||
public static bool CanAccessStatistics(PlanTier tier) => IsProPlus(tier);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ public class Cafe : BaseEntity
|
||||
public PlanTier PlanTier { get; set; } = PlanTier.Free;
|
||||
public DateTime? PlanExpiresAt { get; set; }
|
||||
public bool IsVerified { get; set; }
|
||||
/// <summary>Owner preference: list this café on Koja (public discovery). Defaults true so a
|
||||
/// verified café is discoverable out of the box; the owner can opt out from settings.</summary>
|
||||
public bool ShowOnKoja { get; set; } = true;
|
||||
/// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary>
|
||||
public bool IsSuspended { get; set; }
|
||||
public string? SnappfoodVendorId { get; set; }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Records a client-supplied Idempotency-Key so a retried write (e.g. an order
|
||||
/// replayed from the offline outbox after a lost response) returns the original
|
||||
/// result instead of executing twice. Standalone POCO — deliberately not a
|
||||
/// TenantEntity, to avoid soft-delete/tenant query filters.
|
||||
/// </summary>
|
||||
public class IdempotencyRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>Tenant scope (CafeId), or "global" for non-tenant requests.</summary>
|
||||
public string Scope { get; set; } = "global";
|
||||
|
||||
/// <summary>The client-supplied Idempotency-Key header value.</summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public string Method { get; set; } = string.Empty;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public IdempotencyStatus Status { get; set; } = IdempotencyStatus.InProgress;
|
||||
|
||||
public int ResponseStatusCode { get; set; }
|
||||
public string? ResponseBody { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Meezi.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A stored upload, recorded so identical files can be de-duplicated and reused
|
||||
/// instead of written to disk again. Files with the same content hash within a
|
||||
/// scope (café, or platform when <see cref="CafeId"/> is null) share one stored file.
|
||||
/// </summary>
|
||||
public class MediaAsset : BaseEntity
|
||||
{
|
||||
/// <summary>Owning café, or null for platform-level (admin) uploads.</summary>
|
||||
public string? CafeId { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of the file content, lowercase hex.</summary>
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Public URL/path the file is served from (e.g. /uploads/{cafeId}/{name}).</summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Logical kind/prefix: menu_img, logo, cover, gallery, review, menu_3d, blog, ...</summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string? OriginalFileName { get; set; }
|
||||
}
|
||||
@@ -13,5 +13,13 @@ public class SubscriptionPayment : TenantEntity
|
||||
public string? RefId { get; set; }
|
||||
public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending;
|
||||
|
||||
/// <summary>When this paid period starts. For an immediately-activated purchase this is
|
||||
/// (around) the payment time; for a queued (Scheduled) purchase it is the end of the
|
||||
/// current coverage. Null until the payment completes.</summary>
|
||||
public DateTime? EffectiveFrom { get; set; }
|
||||
|
||||
/// <summary>When this paid period ends (EffectiveFrom + Months). Null until completed.</summary>
|
||||
public DateTime? EffectiveTo { get; set; }
|
||||
|
||||
public Cafe Cafe { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Meezi.Core.Enums;
|
||||
|
||||
public enum IdempotencyStatus
|
||||
{
|
||||
/// <summary>Reserved; the original request is still executing.</summary>
|
||||
InProgress = 0,
|
||||
/// <summary>Finished; the stored response is replayed on duplicate keys.</summary>
|
||||
Completed = 1
|
||||
}
|
||||
@@ -5,5 +5,10 @@ public enum PlanTier
|
||||
Free = 0,
|
||||
Pro = 1,
|
||||
Business = 2,
|
||||
Enterprise = 3
|
||||
Enterprise = 3,
|
||||
// Appended (not inserted) so existing stored tier ints (Cafe / SubscriptionPayment /
|
||||
// PlanDefinition) keep their meaning — no data migration needed. Display & upgrade
|
||||
// ordering is driven by PlatformPlanDefinition.SortOrder, NOT this numeric value,
|
||||
// and gating uses explicit tier checks (never `tier >= X`).
|
||||
Starter = 4
|
||||
}
|
||||
|
||||
@@ -4,5 +4,9 @@ public enum SubscriptionPaymentStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Completed = 1,
|
||||
Failed = 2
|
||||
Failed = 2,
|
||||
/// <summary>Paid, but queued to start after the current coverage ends.</summary>
|
||||
Scheduled = 3,
|
||||
/// <summary>A queued (Scheduled) subscription the owner cancelled before it started.</summary>
|
||||
Cancelled = 4
|
||||
}
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.Core.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Serializable per-plan numeric limits, stored as PlatformPlanDefinition.LimitsJson
|
||||
/// and editable by admins. Missing fields default to "unlimited" (or 0 for opt-in
|
||||
/// quotas) so older stored JSON stays safe. Defaults come from <see cref="PlanLimits"/>.
|
||||
/// </summary>
|
||||
public class PlanLimitsData
|
||||
{
|
||||
public int MaxOrdersPerDay { get; set; } = int.MaxValue;
|
||||
public int MaxTables { get; set; } = int.MaxValue;
|
||||
public int MaxTerminals { get; set; } = int.MaxValue;
|
||||
public int MaxCustomers { get; set; } = int.MaxValue;
|
||||
public int MaxSmsPerMonth { get; set; } = int.MaxValue;
|
||||
public int MaxSmsPerMonth { get; set; } = 0;
|
||||
public int MaxBranches { get; set; } = int.MaxValue;
|
||||
public int MaxReportHistoryDays { get; set; } = int.MaxValue;
|
||||
public int MaxMenuCategories { get; set; } = int.MaxValue;
|
||||
public int MaxMenuItems { get; set; } = int.MaxValue;
|
||||
public int MaxMenuAi3dPerMonth { get; set; } = 0;
|
||||
|
||||
public static PlanLimitsData ForTier(Enums.PlanTier tier) => tier switch
|
||||
public static PlanLimitsData ForTier(PlanTier tier) => new()
|
||||
{
|
||||
Enums.PlanTier.Free => new PlanLimitsData
|
||||
{
|
||||
MaxOrdersPerDay = 50,
|
||||
MaxTerminals = 1,
|
||||
MaxCustomers = 50,
|
||||
MaxSmsPerMonth = 0,
|
||||
MaxBranches = 1,
|
||||
MaxReportHistoryDays = 8
|
||||
},
|
||||
Enums.PlanTier.Pro => new PlanLimitsData
|
||||
{
|
||||
MaxOrdersPerDay = int.MaxValue,
|
||||
MaxTerminals = 3,
|
||||
MaxCustomers = int.MaxValue,
|
||||
MaxSmsPerMonth = 50,
|
||||
MaxBranches = 3,
|
||||
MaxReportHistoryDays = 90
|
||||
},
|
||||
Enums.PlanTier.Business => new PlanLimitsData
|
||||
{
|
||||
MaxOrdersPerDay = int.MaxValue,
|
||||
MaxTerminals = int.MaxValue,
|
||||
MaxCustomers = int.MaxValue,
|
||||
MaxSmsPerMonth = 200,
|
||||
MaxBranches = int.MaxValue,
|
||||
MaxReportHistoryDays = int.MaxValue
|
||||
},
|
||||
_ => new PlanLimitsData()
|
||||
MaxOrdersPerDay = PlanLimits.MaxOrdersPerDay(tier),
|
||||
MaxTables = PlanLimits.MaxTables(tier),
|
||||
MaxTerminals = PlanLimits.MaxTerminals(tier),
|
||||
MaxCustomers = PlanLimits.MaxCustomers(tier),
|
||||
MaxSmsPerMonth = PlanLimits.MaxSmsPerMonth(tier),
|
||||
MaxBranches = PlanLimits.MaxBranches(tier),
|
||||
MaxReportHistoryDays = PlanLimits.MaxReportHistoryDays(tier),
|
||||
MaxMenuCategories = PlanLimits.MaxMenuCategories(tier),
|
||||
MaxMenuItems = PlanLimits.MaxMenuItems(tier),
|
||||
MaxMenuAi3dPerMonth = PlanLimits.MaxMenuAi3dPerMonth(tier),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,10 +82,28 @@ public class AppDbContext : DbContext
|
||||
// Immutable audit trail of sensitive POS / management actions.
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
|
||||
// Idempotency keys for safe retry of offline-replayed writes.
|
||||
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
|
||||
|
||||
// Uploaded files, recorded for content-hash de-duplication and a media library.
|
||||
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<IdempotencyRecord>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
// One result per (tenant, key). The unique index also serializes
|
||||
// concurrent first-time requests carrying the same key.
|
||||
e.HasIndex(x => new { x.Scope, x.Key }).IsUnique();
|
||||
e.Property(x => x.Scope).HasMaxLength(64).IsRequired();
|
||||
e.Property(x => x.Key).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Method).HasMaxLength(10).IsRequired();
|
||||
e.Property(x => x.Path).HasMaxLength(512).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PushDevice>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
@@ -98,6 +116,20 @@ public class AppDbContext : DbContext
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MediaAsset>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
// Dedup lookups: same content within a scope (café or platform).
|
||||
e.HasIndex(x => new { x.CafeId, x.ContentHash });
|
||||
e.Property(x => x.CafeId).HasMaxLength(64);
|
||||
e.Property(x => x.ContentHash).HasMaxLength(64).IsRequired();
|
||||
e.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
|
||||
e.Property(x => x.Url).HasMaxLength(500).IsRequired();
|
||||
e.Property(x => x.Kind).HasMaxLength(40).IsRequired();
|
||||
e.Property(x => x.OriginalFileName).HasMaxLength(260);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Cafe>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
@@ -111,6 +143,9 @@ public class AppDbContext : DbContext
|
||||
e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000);
|
||||
e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000);
|
||||
e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2);
|
||||
// Default true at the DB level so existing cafés stay listed on Koja after
|
||||
// the column is added (EF doesn't read the C# initializer for the SQL default).
|
||||
e.Property(x => x.ShowOnKoja).HasDefaultValue(true);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,28 @@ namespace Meezi.Infrastructure.Data;
|
||||
/// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary>
|
||||
public static class DiscoverShowcaseSeeder
|
||||
{
|
||||
// Approximate city centres. Each café is scattered around its city with a
|
||||
// small deterministic offset (derived from its id) so the marketing map
|
||||
// shows a realistic cluster of blinking lights instead of one stacked dot.
|
||||
private static readonly Dictionary<string, (double Lat, double Lng, double Spread)> CityGeo = new()
|
||||
{
|
||||
["تهران"] = (35.70, 51.39, 0.13),
|
||||
["کرج"] = (35.83, 50.99, 0.07),
|
||||
};
|
||||
|
||||
private static (double Lat, double Lng) GeoFor(string id, string city)
|
||||
{
|
||||
var (lat, lng, spread) = CityGeo.TryGetValue(city, out var g) ? g : (35.70, 51.39, 0.13);
|
||||
unchecked
|
||||
{
|
||||
var h = 17;
|
||||
foreach (var ch in id) h = (h * 31) + ch;
|
||||
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
|
||||
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
|
||||
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"];
|
||||
private static readonly string[] ReviewComments =
|
||||
[
|
||||
@@ -27,6 +49,7 @@ public static class DiscoverShowcaseSeeder
|
||||
foreach (var spec in DiscoverShowcaseCatalog.Cafes)
|
||||
{
|
||||
var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id);
|
||||
var (geoLat, geoLng) = GeoFor(spec.Id, spec.City);
|
||||
if (cafe is null)
|
||||
{
|
||||
cafe = new Cafe
|
||||
@@ -37,6 +60,8 @@ public static class DiscoverShowcaseSeeder
|
||||
Slug = spec.Slug,
|
||||
City = spec.City,
|
||||
Address = spec.Address,
|
||||
Latitude = geoLat,
|
||||
Longitude = geoLng,
|
||||
Description = spec.Description,
|
||||
PlanTier = spec.PlanTier,
|
||||
PreferredLanguage = "fa",
|
||||
@@ -100,6 +125,12 @@ public static class DiscoverShowcaseSeeder
|
||||
cafe.IsVerified = true;
|
||||
changed = true;
|
||||
}
|
||||
if (cafe.Latitude is null || cafe.Longitude is null)
|
||||
{
|
||||
cafe.Latitude = geoLat;
|
||||
cafe.Longitude = geoLng;
|
||||
changed = true;
|
||||
}
|
||||
if (changed)
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
+3310
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCafeShowOnKoja : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ShowOnKoja",
|
||||
table: "Cafes",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowOnKoja",
|
||||
table: "Cafes");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3316
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSubscriptionScheduling : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EffectiveFrom",
|
||||
table: "SubscriptionPayments",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EffectiveTo",
|
||||
table: "SubscriptionPayments",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EffectiveFrom",
|
||||
table: "SubscriptionPayments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EffectiveTo",
|
||||
table: "SubscriptionPayments");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3364
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIdempotencyRecords : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "IdempotencyRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Scope = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
Path = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
ResponseStatusCode = table.Column<int>(type: "integer", nullable: false),
|
||||
ResponseBody = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_IdempotencyRecords", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_IdempotencyRecords_Scope_Key",
|
||||
table: "IdempotencyRecords",
|
||||
columns: new[] { "Scope", "Key" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "IdempotencyRecords");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3413
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaAssets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MediaAssets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
CafeId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Url = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MediaAssets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaAssets_CafeId_ContentHash",
|
||||
table: "MediaAssets",
|
||||
columns: new[] { "CafeId", "ContentHash" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MediaAssets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,6 +360,11 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("ShowOnKoja")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -1124,6 +1129,54 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.ToTable("Expenses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.IdempotencyRecord", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Method")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("ResponseBody")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ResponseStatusCode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Scope", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("IdempotencyRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -1255,6 +1308,55 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.ToTable("LeaveRequests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.MediaAsset", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CafeId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("character varying(260)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CafeId", "ContentHash");
|
||||
|
||||
b.ToTable("MediaAssets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -2006,6 +2108,12 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("EffectiveFrom")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("EffectiveTo")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Months")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -29,6 +29,25 @@ public static class PlatformDataSeeder
|
||||
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
|
||||
await EnsureOwnerAdminAsync(db, config, logger);
|
||||
|
||||
// Best-effort, NON-FATAL seeding. These steps populate convenience data
|
||||
// (map pins, plan/feature catalog) and must never crash-loop the API on
|
||||
// boot — a failure is logged and startup continues so the service serves.
|
||||
try
|
||||
{
|
||||
// Give cafés without a map pin an approximate location from their
|
||||
// city so the public map lights up. Idempotent (fills nulls).
|
||||
await BackfillCafeLocationsAsync(db, logger);
|
||||
|
||||
// Subscription plans + feature flags the admin panel reads in every
|
||||
// environment. Idempotent: adds any tiers/keys that are missing.
|
||||
await SeedPlansAsync(db, logger);
|
||||
await SeedFeaturesAsync(db, logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Non-fatal platform seeding step failed; continuing startup");
|
||||
}
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
{
|
||||
// Production: also ensure integration settings (Kavenegar enabled/template,
|
||||
@@ -39,12 +58,83 @@ public static class PlatformDataSeeder
|
||||
|
||||
await EnsureCatalogUpgradesAsync(db, logger);
|
||||
await SeedSystemAdminAsync(db, logger);
|
||||
await SeedPlansAsync(db, logger);
|
||||
await SeedFeaturesAsync(db, logger);
|
||||
await SeedSettingsAsync(db, logger);
|
||||
await EnsureIntegrationSettingsAsync(db, logger);
|
||||
}
|
||||
|
||||
// Approximate centres for the major Iranian cities cafés sign up from.
|
||||
private static readonly Dictionary<string, (double Lat, double Lng)> CityCentres = new(StringComparer.Ordinal)
|
||||
{
|
||||
["تهران"] = (35.70, 51.39),
|
||||
["کرج"] = (35.84, 50.99),
|
||||
["مشهد"] = (36.30, 59.61),
|
||||
["اصفهان"] = (32.66, 51.67),
|
||||
["شیراز"] = (29.59, 52.53),
|
||||
["تبریز"] = (38.08, 46.29),
|
||||
["قم"] = (34.64, 50.88),
|
||||
["اهواز"] = (31.32, 48.67),
|
||||
["کرمانشاه"] = (34.31, 47.07),
|
||||
["رشت"] = (37.28, 49.58),
|
||||
["ارومیه"] = (37.55, 45.07),
|
||||
["همدان"] = (34.80, 48.52),
|
||||
["یزد"] = (31.90, 54.37),
|
||||
["اراک"] = (34.09, 49.69),
|
||||
["کرمان"] = (30.28, 57.08),
|
||||
["بندرعباس"] = (27.18, 56.27),
|
||||
["قزوین"] = (36.28, 50.00),
|
||||
["ساری"] = (36.57, 53.06),
|
||||
["گرگان"] = (36.84, 54.44),
|
||||
["زنجان"] = (36.68, 48.49),
|
||||
["کیش"] = (26.56, 53.98),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gives cafés that have no map pin an approximate location at their city
|
||||
/// centre (plus a small deterministic per-café offset so multiple cafés in
|
||||
/// one city don't stack on a single point). Only fills rows where Latitude or
|
||||
/// Longitude is null and the city is recognised; owners can drop an exact pin
|
||||
/// later from Settings. Idempotent — never overwrites an existing pin.
|
||||
/// </summary>
|
||||
private static async Task BackfillCafeLocationsAsync(AppDbContext db, ILogger logger)
|
||||
{
|
||||
var cafes = await db.Cafes
|
||||
.Where(c => c.DeletedAt == null
|
||||
&& (c.Latitude == null || c.Longitude == null)
|
||||
&& c.City != null)
|
||||
.ToListAsync();
|
||||
if (cafes.Count == 0) return;
|
||||
|
||||
var updated = 0;
|
||||
foreach (var cafe in cafes)
|
||||
{
|
||||
var city = cafe.City!.Trim();
|
||||
if (!CityCentres.TryGetValue(city, out var centre)) continue;
|
||||
var (lat, lng) = ScatterAround(cafe.Id, centre.Lat, centre.Lng, 0.05);
|
||||
cafe.Latitude = lat;
|
||||
cafe.Longitude = lng;
|
||||
updated++;
|
||||
}
|
||||
|
||||
if (updated > 0)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"Cafe location backfill: set approximate coordinates for {Count} café(s) from city centre", updated);
|
||||
}
|
||||
}
|
||||
|
||||
private static (double Lat, double Lng) ScatterAround(string id, double lat, double lng, double spread)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var h = 17;
|
||||
foreach (var ch in id) h = (h * 31) + ch;
|
||||
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
|
||||
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
|
||||
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the platform owner's system-admin account exists in EVERY environment
|
||||
/// (including production), so the admin panel is reachable on a fresh deploy.
|
||||
@@ -184,50 +274,47 @@ public static class PlatformDataSeeder
|
||||
logger.LogInformation("Platform upgrade: added {Count} features", newFeatures.Count);
|
||||
}
|
||||
|
||||
var plans = await db.PlatformPlanDefinitions.ToListAsync();
|
||||
var changed = 0;
|
||||
foreach (var plan in plans)
|
||||
// One-time: bring plan definitions to the current matrix (Free·Starter·Pro·
|
||||
// Business·Enterprise). Existing plans were never admin-editable before this, so
|
||||
// updating their limits/features/order/price to the canonical defaults is safe.
|
||||
// Version-guarded so it runs once and never clobbers later admin edits.
|
||||
const string matrixVersionKey = "catalog.planMatrixVersion";
|
||||
const string matrixVersion = "2";
|
||||
var verSetting = await db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == matrixVersionKey);
|
||||
if (verSetting?.Value != matrixVersion)
|
||||
{
|
||||
if (plan.Tier is PlanTier.Free or PlanTier.Enterprise)
|
||||
continue;
|
||||
|
||||
var keys = plan.Tier == PlanTier.Business || plan.Tier == PlanTier.Enterprise
|
||||
? new[] { "menu_3d", "menu_3d_ai", "discover_profile" }
|
||||
: new[] { "menu_3d", "discover_profile" };
|
||||
var merged = MergeFeaturesJson(plan.FeaturesJson ?? "[]", keys);
|
||||
if (merged == plan.FeaturesJson) continue;
|
||||
plan.FeaturesJson = merged;
|
||||
changed++;
|
||||
// Tier is unique across all rows (incl. soft-deleted), so at most one row per tier.
|
||||
var byTier = (await db.PlatformPlanDefinitions.IgnoreQueryFilters().ToListAsync())
|
||||
.ToDictionary(p => p.Tier);
|
||||
foreach (var def in CanonicalPlans())
|
||||
{
|
||||
if (byTier.TryGetValue(def.Tier, out var ex))
|
||||
{
|
||||
ex.DisplayNameFa = def.DisplayNameFa;
|
||||
ex.DisplayNameEn = def.DisplayNameEn;
|
||||
ex.MonthlyPriceToman = def.MonthlyPriceToman;
|
||||
ex.IsBillableOnline = def.IsBillableOnline;
|
||||
ex.SortOrder = def.SortOrder;
|
||||
ex.LimitsJson = def.LimitsJson;
|
||||
ex.FeaturesJson = def.FeaturesJson;
|
||||
ex.DeletedAt = null; // ensure all five plans are active
|
||||
}
|
||||
|
||||
if (changed > 0)
|
||||
else
|
||||
{
|
||||
db.PlatformPlanDefinitions.Add(def);
|
||||
}
|
||||
}
|
||||
if (verSetting is null)
|
||||
db.PlatformSettings.Add(S(matrixVersionKey, matrixVersion, "catalog", "نسخه ماتریس پلنها"));
|
||||
else
|
||||
verSetting.Value = matrixVersion;
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Platform upgrade: updated features on {Count} plans", changed);
|
||||
logger.LogInformation("Platform upgrade: applied plan matrix v{Version}", matrixVersion);
|
||||
}
|
||||
|
||||
await EnsureIntegrationSettingsAsync(db, logger);
|
||||
}
|
||||
|
||||
private static string MergeFeaturesJson(string json, params string[] keys)
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<string>>(json, JsonOpts) ?? [];
|
||||
if (list.Contains("*"))
|
||||
return json;
|
||||
|
||||
var updated = false;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!list.Contains(key))
|
||||
{
|
||||
list.Add(key);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return updated ? JsonSerializer.Serialize(list, JsonOpts) : json;
|
||||
}
|
||||
|
||||
private static async Task EnsureIntegrationSettingsAsync(AppDbContext db, ILogger logger)
|
||||
{
|
||||
var defaults = new[]
|
||||
@@ -278,82 +365,68 @@ public static class PlatformDataSeeder
|
||||
logger.LogInformation("Platform seed: system admin phone {Phone}", phone);
|
||||
}
|
||||
|
||||
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
|
||||
// ── Canonical plan matrix (Free·Starter·Pro·Business·Enterprise) ─────────────
|
||||
// Single source of plan DEFAULTS. Free features are broad (KDS, queue, Koja,
|
||||
// offline, reviews, reservations, coupons, employees); paid tiers add the rest.
|
||||
private static readonly string[] FreeFeatures =
|
||||
{
|
||||
if (await db.PlatformPlanDefinitions.AnyAsync())
|
||||
return;
|
||||
"pos", "menu", "tables", "qr_menu", "kds", "queue", "inventory",
|
||||
"reservations", "reviews", "coupons", "discover_profile", "offline", "employees"
|
||||
};
|
||||
private static readonly string[] StarterFeatures =
|
||||
FreeFeatures.Concat(new[] { "watermark_removed", "custom_menu_styling", "review_reply" }).ToArray();
|
||||
private static readonly string[] ProFeatures =
|
||||
StarterFeatures.Concat(new[] { "crm", "reports", "taxes", "hr", "delivery", "expenses", "branches" }).ToArray();
|
||||
private static readonly string[] BusinessFeatures =
|
||||
ProFeatures.Concat(new[] { "menu_3d", "menu_3d_ai" }).ToArray();
|
||||
|
||||
var plans = new[]
|
||||
private static PlatformPlanDefinition Plan(
|
||||
string id, PlanTier tier, string fa, string en, decimal price, bool billable, int sort, string[] features) => new()
|
||||
{
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_free",
|
||||
Tier = PlanTier.Free,
|
||||
DisplayNameFa = "رایگان",
|
||||
DisplayNameEn = "Free",
|
||||
MonthlyPriceToman = 0,
|
||||
IsBillableOnline = false,
|
||||
SortOrder = 0,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Free), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[] { "pos", "menu", "tables", "qr_menu" }, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_pro",
|
||||
Tier = PlanTier.Pro,
|
||||
DisplayNameFa = "حرفهای",
|
||||
DisplayNameEn = "Pro",
|
||||
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Pro),
|
||||
IsBillableOnline = true,
|
||||
SortOrder = 1,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Pro), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
|
||||
"menu_3d", "discover_profile"
|
||||
}, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_business",
|
||||
Tier = PlanTier.Business,
|
||||
DisplayNameFa = "کسبوکار",
|
||||
DisplayNameEn = "Business",
|
||||
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Business),
|
||||
IsBillableOnline = true,
|
||||
SortOrder = 2,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Business), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
|
||||
"hr", "sms", "reservations", "delivery", "expenses", "branches",
|
||||
"menu_3d", "menu_3d_ai", "discover_profile"
|
||||
}, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_enterprise",
|
||||
Tier = PlanTier.Enterprise,
|
||||
DisplayNameFa = "سازمانی",
|
||||
DisplayNameEn = "Enterprise",
|
||||
MonthlyPriceToman = 0,
|
||||
IsBillableOnline = false,
|
||||
SortOrder = 3,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Enterprise), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[] { "*" }, JsonOpts)
|
||||
}
|
||||
Id = id,
|
||||
Tier = tier,
|
||||
DisplayNameFa = fa,
|
||||
DisplayNameEn = en,
|
||||
MonthlyPriceToman = price,
|
||||
IsBillableOnline = billable,
|
||||
SortOrder = sort,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(tier), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(features, JsonOpts)
|
||||
};
|
||||
|
||||
db.PlatformPlanDefinitions.AddRange(plans);
|
||||
private static PlatformPlanDefinition[] CanonicalPlans() =>
|
||||
[
|
||||
Plan("plan_free", PlanTier.Free, "رایگان", "Free", 0, false, 0, FreeFeatures),
|
||||
Plan("plan_starter", PlanTier.Starter, "پایه", "Starter", 690_000, true, 1, StarterFeatures),
|
||||
Plan("plan_pro", PlanTier.Pro, "حرفهای", "Pro", 1_490_000, true, 2, ProFeatures),
|
||||
Plan("plan_business", PlanTier.Business, "کسبوکار", "Business", 3_490_000, true, 3, BusinessFeatures),
|
||||
Plan("plan_enterprise", PlanTier.Enterprise, "سازمانی", "Enterprise", 0, false, 4, new[] { "*" }),
|
||||
];
|
||||
|
||||
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
|
||||
{
|
||||
var plans = CanonicalPlans();
|
||||
|
||||
// Tier (not Id) carries the unique constraint, so dedupe on Tier — an
|
||||
// existing Free plan may have a different Id, and inserting another
|
||||
// Free-tier row would violate IX_PlatformPlanDefinitions_Tier.
|
||||
// IgnoreQueryFilters: a SOFT-DELETED plan still occupies its Tier in the
|
||||
// unique index, so it must be counted or the insert collides on boot.
|
||||
var existingTiers = (await db.PlatformPlanDefinitions
|
||||
.IgnoreQueryFilters()
|
||||
.Select(p => p.Tier)
|
||||
.ToListAsync())
|
||||
.ToHashSet();
|
||||
var missing = plans.Where(p => !existingTiers.Contains(p.Tier)).ToArray();
|
||||
if (missing.Length == 0) return;
|
||||
|
||||
db.PlatformPlanDefinitions.AddRange(missing);
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Platform seed: {Count} subscription plans", plans.Length);
|
||||
logger.LogInformation("Platform seed: +{Count} subscription plans", missing.Length);
|
||||
}
|
||||
|
||||
private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger)
|
||||
{
|
||||
if (await db.PlatformFeatures.AnyAsync())
|
||||
return;
|
||||
|
||||
var features = new[]
|
||||
{
|
||||
F("pos", "صندوق", "POS", "core"),
|
||||
@@ -376,12 +449,29 @@ public static class PlatformDataSeeder
|
||||
F("queue", "صف", "Queue", "operations"),
|
||||
F("menu_3d", "منوی سهبعدی", "3D menu", "growth"),
|
||||
F("menu_3d_ai", "تولید ۳D با هوش مصنوعی", "AI 3D menu", "growth"),
|
||||
F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
|
||||
F("discover_profile", "پروفایل کشف", "Discover profile", "growth"),
|
||||
F("offline", "حالت آفلاین", "Offline mode", "core"),
|
||||
F("employees", "کارکنان", "Employees", "operations"),
|
||||
F("watermark_removed", "حذف واترمارک منو", "Remove menu watermark", "growth"),
|
||||
F("custom_menu_styling", "طراحی اختصاصی منو", "Custom menu styling", "growth"),
|
||||
F("review_reply", "پاسخ به نظرات", "Reply to reviews", "growth"),
|
||||
F("api", "API عمومی", "Public API", "integrations"),
|
||||
F("white_label", "وایتلیبل", "White-label", "integrations")
|
||||
};
|
||||
|
||||
db.PlatformFeatures.AddRange(features);
|
||||
// Key carries the unique constraint, so dedupe on Key (not Id).
|
||||
// IgnoreQueryFilters so a soft-deleted feature's Key is still counted.
|
||||
var existingKeys = (await db.PlatformFeatures
|
||||
.IgnoreQueryFilters()
|
||||
.Select(f => f.Key)
|
||||
.ToListAsync())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var missing = features.Where(f => !existingKeys.Contains(f.Key)).ToArray();
|
||||
if (missing.Length == 0) return;
|
||||
|
||||
db.PlatformFeatures.AddRange(missing);
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Platform seed: {Count} feature flags", features.Length);
|
||||
logger.LogInformation("Platform seed: +{Count} feature flags", missing.Length);
|
||||
}
|
||||
|
||||
private static PlatformFeature F(string key, string fa, string en, string group) => new()
|
||||
|
||||
@@ -196,8 +196,9 @@ public class DailyReportServiceTests
|
||||
public void ReportPlanGate_Free_AllowsEightDayWindow()
|
||||
{
|
||||
var today = new DateOnly(2026, 5, 21);
|
||||
Assert.True(ReportPlanGate.IsDateInRange(PlanTier.Free, today, today));
|
||||
Assert.True(ReportPlanGate.IsDateInRange(PlanTier.Free, today.AddDays(-7), today));
|
||||
Assert.False(ReportPlanGate.IsDateInRange(PlanTier.Free, today.AddDays(-8), today));
|
||||
const int freeMaxDays = 8; // Free plan's report-history window
|
||||
Assert.True(ReportPlanGate.IsDateInRange(freeMaxDays, today, today));
|
||||
Assert.True(ReportPlanGate.IsDateInRange(freeMaxDays, today.AddDays(-7), today));
|
||||
Assert.False(ReportPlanGate.IsDateInRange(freeMaxDays, today.AddDays(-8), today));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Meezi.API.Middleware;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Xunit;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
public class IdempotencyMiddlewareTests
|
||||
{
|
||||
private sealed class TestTenant(string? cafeId) : ITenantContext
|
||||
{
|
||||
public string? UserId => "user-1";
|
||||
public string? CafeId => cafeId;
|
||||
public EmployeeRole? Role => EmployeeRole.Owner;
|
||||
public PlanTier? PlanTier => Core.Enums.PlanTier.Pro;
|
||||
public string? Language => "fa";
|
||||
public string? BranchId => null;
|
||||
public bool IsSystemAdmin => false;
|
||||
public bool IsAuthenticated => true;
|
||||
}
|
||||
|
||||
/// <summary>A scope factory whose scopes share one in-memory database, mirroring how the
|
||||
/// middleware opens isolated DI scopes against the same store in production.</summary>
|
||||
private static IServiceScopeFactory BuildScopeFactory()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
var services = new ServiceCollection();
|
||||
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase(dbName));
|
||||
services.AddLogging();
|
||||
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||
}
|
||||
|
||||
private static DefaultHttpContext NewPost(string? key)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Method = "POST";
|
||||
ctx.Request.Path = "/api/test";
|
||||
if (key is not null) ctx.Request.Headers["Idempotency-Key"] = key;
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static string ReadBody(HttpContext ctx)
|
||||
{
|
||||
ctx.Response.Body.Position = 0;
|
||||
return new StreamReader(ctx.Response.Body).ReadToEnd();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SameKey_ExecutesOnce_AndReplaysStoredResponse()
|
||||
{
|
||||
var scopeFactory = BuildScopeFactory();
|
||||
var tenant = new TestTenant("cafe-1");
|
||||
var calls = 0;
|
||||
RequestDelegate next = async ctx =>
|
||||
{
|
||||
calls++;
|
||||
ctx.Response.StatusCode = 200;
|
||||
await ctx.Response.WriteAsync($"{{\"v\":\"{Guid.NewGuid():N}\"}}");
|
||||
};
|
||||
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||
|
||||
var c1 = NewPost("KEY-1");
|
||||
await mw.InvokeAsync(c1, tenant, scopeFactory);
|
||||
var body1 = ReadBody(c1);
|
||||
|
||||
var c2 = NewPost("KEY-1");
|
||||
await mw.InvokeAsync(c2, tenant, scopeFactory);
|
||||
var body2 = ReadBody(c2);
|
||||
|
||||
Assert.Equal(1, calls); // executed exactly once
|
||||
Assert.Equal(body1, body2); // second call replays the stored body verbatim
|
||||
Assert.Equal(200, c2.Response.StatusCode);
|
||||
Assert.Equal("true", c2.Response.Headers["Idempotent-Replay"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DifferentKey_ExecutesAgain()
|
||||
{
|
||||
var scopeFactory = BuildScopeFactory();
|
||||
var tenant = new TestTenant("cafe-1");
|
||||
var calls = 0;
|
||||
RequestDelegate next = async ctx =>
|
||||
{
|
||||
calls++;
|
||||
ctx.Response.StatusCode = 200;
|
||||
await ctx.Response.WriteAsync("{\"ok\":true}");
|
||||
};
|
||||
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||
|
||||
await mw.InvokeAsync(NewPost("A"), tenant, scopeFactory);
|
||||
await mw.InvokeAsync(NewPost("B"), tenant, scopeFactory);
|
||||
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoKey_PassesThrough_NoIdempotency()
|
||||
{
|
||||
var scopeFactory = BuildScopeFactory();
|
||||
var tenant = new TestTenant("cafe-1");
|
||||
var calls = 0;
|
||||
RequestDelegate next = ctx =>
|
||||
{
|
||||
calls++;
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||
|
||||
await mw.InvokeAsync(NewPost(null), tenant, scopeFactory);
|
||||
await mw.InvokeAsync(NewPost(null), tenant, scopeFactory);
|
||||
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SameKey_DifferentTenant_IsNotReplayed()
|
||||
{
|
||||
var scopeFactory = BuildScopeFactory();
|
||||
var calls = 0;
|
||||
RequestDelegate next = async ctx =>
|
||||
{
|
||||
calls++;
|
||||
ctx.Response.StatusCode = 200;
|
||||
await ctx.Response.WriteAsync("{\"ok\":true}");
|
||||
};
|
||||
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||
|
||||
await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-A"), scopeFactory);
|
||||
await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-B"), scopeFactory);
|
||||
|
||||
Assert.Equal(2, calls); // keys are scoped per café — no cross-tenant collision
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerError_IsNotCached_SoRetryReexecutes()
|
||||
{
|
||||
var scopeFactory = BuildScopeFactory();
|
||||
var tenant = new TestTenant("cafe-1");
|
||||
var calls = 0;
|
||||
RequestDelegate next = async ctx =>
|
||||
{
|
||||
calls++;
|
||||
ctx.Response.StatusCode = 500;
|
||||
await ctx.Response.WriteAsync("{\"error\":\"boom\"}");
|
||||
};
|
||||
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||
|
||||
await mw.InvokeAsync(NewPost("KEY-5XX"), tenant, scopeFactory);
|
||||
var c2 = NewPost("KEY-5XX");
|
||||
await mw.InvokeAsync(c2, tenant, scopeFactory);
|
||||
|
||||
Assert.Equal(2, calls); // 5xx is transient → reservation released, retry runs again
|
||||
Assert.NotEqual("true", c2.Response.Headers["Idempotent-Replay"].ToString());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ internal sealed class NoOpInventoryService : IInventoryService
|
||||
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
|
||||
Task.FromResult<IngredientDto?>(null);
|
||||
|
||||
public Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IngredientDto?>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Platform;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
|
||||
namespace Meezi.API.Tests;
|
||||
|
||||
/// <summary>Test double: every feature enabled, unlimited limits. Keeps plan gating
|
||||
/// out of the way for service-level tests.</summary>
|
||||
internal sealed class NoOpPlatformCatalogService : IPlatformCatalogService
|
||||
{
|
||||
public Task<IReadOnlyList<PlanDefinitionDto>> GetPlansAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<PlanDefinitionDto>>([]);
|
||||
|
||||
public Task<PlanDefinitionDto?> GetPlanAsync(PlanTier tier, CancellationToken ct = default) =>
|
||||
Task.FromResult<PlanDefinitionDto?>(null);
|
||||
|
||||
public Task<PlanLimitsData> GetLimitsAsync(PlanTier tier, CancellationToken ct = default) =>
|
||||
Task.FromResult(new PlanLimitsData());
|
||||
|
||||
public Task<decimal> GetMonthlyPriceTomanAsync(PlanTier tier, CancellationToken ct = default) =>
|
||||
Task.FromResult(0m);
|
||||
|
||||
public Task<bool> IsBillableOnlineAsync(PlanTier tier, CancellationToken ct = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<IReadOnlyList<PlatformSettingDto>> GetSettingsAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<PlatformSettingDto>>([]);
|
||||
|
||||
public Task<string?> GetSettingAsync(string key, CancellationToken ct = default) =>
|
||||
Task.FromResult<string?>(null);
|
||||
|
||||
public Task<IReadOnlyList<PlatformFeatureDto>> GetFeaturesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<PlatformFeatureDto>>([]);
|
||||
|
||||
public Task<IReadOnlyDictionary<string, bool>> GetEffectiveFeaturesForCafeAsync(
|
||||
string cafeId, PlanTier planTier, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyDictionary<string, bool>>(new Dictionary<string, bool>());
|
||||
|
||||
public Task<bool> IsFeatureEnabledForCafeAsync(
|
||||
string cafeId, PlanTier planTier, string featureKey, CancellationToken ct = default) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public void InvalidateCache() { }
|
||||
}
|
||||
@@ -120,7 +120,7 @@ public class QrMenuTests
|
||||
var http = new HttpContextAccessor();
|
||||
var media = new NoOpMediaStorageService();
|
||||
var reviews = new ReviewService(db, abuse, http, media);
|
||||
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http);
|
||||
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http, new NoOpPlatformCatalogService());
|
||||
|
||||
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
|
||||
}
|
||||
|
||||
@@ -1055,8 +1055,8 @@
|
||||
"fieldExcerptEn": "الملخص بالإنجليزية",
|
||||
"fieldCategoryFa": "الفئة بالفارسية",
|
||||
"fieldCategoryEn": "الفئة بالإنجليزية",
|
||||
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
|
||||
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
|
||||
"fieldContentFa": "المحتوى (فارسي)",
|
||||
"fieldContentEn": "المحتوى (إنجليزي)",
|
||||
"fieldPublished": "منشور",
|
||||
"commentsTitle": "إدارة التعليقات",
|
||||
"noComments": "لا توجد تعليقات",
|
||||
@@ -1114,7 +1114,29 @@
|
||||
"title": "خطط الاشتراك",
|
||||
"monthlyPrice": "السعر الشهري (تومان)",
|
||||
"maxOrders": "حد الطلبات اليومي",
|
||||
"saved": "تم الحفظ"
|
||||
"saved": "تم الحفظ",
|
||||
"active": "مفعل",
|
||||
"nameFa": "الاسم (فارسي)",
|
||||
"nameEn": "الاسم (إنجليزي)",
|
||||
"sortOrder": "الترتيب",
|
||||
"billable": "قابل للدفع عبر الإنترنت",
|
||||
"limitsTitle": "الحدود",
|
||||
"featuresTitle": "الميزات",
|
||||
"allFeatures": "كل الميزات",
|
||||
"allFeaturesNote": "تشمل هذه الباقة جميع الميزات الحالية والمستقبلية.",
|
||||
"save": "حفظ",
|
||||
"limits": {
|
||||
"maxOrders": "طلبات/يوم",
|
||||
"maxTables": "الطاولات",
|
||||
"maxTerminals": "أجهزة POS",
|
||||
"maxBranches": "الفروع",
|
||||
"maxCategories": "فئات القائمة",
|
||||
"maxItems": "أصناف القائمة",
|
||||
"maxCustomers": "العملاء",
|
||||
"maxReportDays": "سجل التقارير (أيام)",
|
||||
"maxSms": "رسائل/شهر",
|
||||
"maxAi3d": "3D/شهر"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "إعدادات التطبيق",
|
||||
@@ -1241,8 +1263,13 @@
|
||||
"instagramLabel": "إنستغرام",
|
||||
"websiteLabel": "الموقع",
|
||||
"days": {
|
||||
"sat": "السبت", "sun": "الأحد", "mon": "الاثنين",
|
||||
"tue": "الثلاثاء", "wed": "الأربعاء", "thu": "الخميس", "fri": "الجمعة"
|
||||
"sat": "السبت",
|
||||
"sun": "الأحد",
|
||||
"mon": "الاثنين",
|
||||
"tue": "الثلاثاء",
|
||||
"wed": "الأربعاء",
|
||||
"thu": "الخميس",
|
||||
"fri": "الجمعة"
|
||||
},
|
||||
"coffeeAdvisor": {
|
||||
"title": "مستشار المشروبات",
|
||||
@@ -1253,7 +1280,10 @@
|
||||
"notConfigured": "المستشار الذكي غير مفعّل لهذا المقهى بعد",
|
||||
"failed": "الاقتراحات غير متاحة. حاول لاحقاً"
|
||||
},
|
||||
"cities": { "tehran": "طهران", "karaj": "كرج" },
|
||||
"cities": {
|
||||
"tehran": "طهران",
|
||||
"karaj": "كرج"
|
||||
},
|
||||
"sort": {
|
||||
"rating": "الأعلى تقييماً",
|
||||
"reviews": "الأكثر مراجعات",
|
||||
@@ -1296,8 +1326,13 @@
|
||||
"openTime": "يفتح الساعة",
|
||||
"closeTime": "يغلق الساعة",
|
||||
"days": {
|
||||
"sat": "السبت", "sun": "الأحد", "mon": "الاثنين",
|
||||
"tue": "الثلاثاء", "wed": "الأربعاء", "thu": "الخميس", "fri": "الجمعة"
|
||||
"sat": "السبت",
|
||||
"sun": "الأحد",
|
||||
"mon": "الاثنين",
|
||||
"tue": "الثلاثاء",
|
||||
"wed": "الأربعاء",
|
||||
"thu": "الخميس",
|
||||
"fri": "الجمعة"
|
||||
},
|
||||
"save": "حفظ",
|
||||
"saved": "تم الحفظ",
|
||||
|
||||
@@ -1056,8 +1056,8 @@
|
||||
"fieldExcerptEn": "Excerpt (English)",
|
||||
"fieldCategoryFa": "Category (Persian)",
|
||||
"fieldCategoryEn": "Category (English)",
|
||||
"fieldContentFa": "Content (Persian, Markdown)",
|
||||
"fieldContentEn": "Content (English, Markdown)",
|
||||
"fieldContentFa": "Content (Persian)",
|
||||
"fieldContentEn": "Content (English)",
|
||||
"fieldPublished": "Published",
|
||||
"commentsTitle": "Comment management",
|
||||
"noComments": "No comments found",
|
||||
@@ -1107,7 +1107,29 @@
|
||||
"title": "Subscription plans",
|
||||
"monthlyPrice": "Monthly price (Toman)",
|
||||
"maxOrders": "Max orders per day",
|
||||
"saved": "Plan saved"
|
||||
"saved": "Plan saved",
|
||||
"active": "Active",
|
||||
"nameFa": "Name (Persian)",
|
||||
"nameEn": "Name (English)",
|
||||
"sortOrder": "Sort order",
|
||||
"billable": "Billable online",
|
||||
"limitsTitle": "Limits",
|
||||
"featuresTitle": "Features",
|
||||
"allFeatures": "All features",
|
||||
"allFeaturesNote": "This plan includes all features (current and future).",
|
||||
"save": "Save",
|
||||
"limits": {
|
||||
"maxOrders": "Orders/day",
|
||||
"maxTables": "Tables",
|
||||
"maxTerminals": "POS terminals",
|
||||
"maxBranches": "Branches",
|
||||
"maxCategories": "Menu categories",
|
||||
"maxItems": "Menu items",
|
||||
"maxCustomers": "Customers",
|
||||
"maxReportDays": "Report history (days)",
|
||||
"maxSms": "SMS/month",
|
||||
"maxAi3d": "AI 3D/month"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Application settings",
|
||||
@@ -1219,7 +1241,10 @@
|
||||
"notFound": "Café not found",
|
||||
"exploreMore": "Explore more cafés",
|
||||
"reviewCount": "{count} reviews",
|
||||
"cities": { "tehran": "Tehran", "karaj": "Karaj" },
|
||||
"cities": {
|
||||
"tehran": "Tehran",
|
||||
"karaj": "Karaj"
|
||||
},
|
||||
"sort": {
|
||||
"rating": "Top rated",
|
||||
"reviews": "Most reviews",
|
||||
|
||||
@@ -1056,8 +1056,8 @@
|
||||
"fieldExcerptEn": "خلاصه انگلیسی",
|
||||
"fieldCategoryFa": "دستهبندی فارسی",
|
||||
"fieldCategoryEn": "دستهبندی انگلیسی",
|
||||
"fieldContentFa": "محتوا فارسی (Markdown)",
|
||||
"fieldContentEn": "محتوا انگلیسی (Markdown)",
|
||||
"fieldContentFa": "محتوا (فارسی)",
|
||||
"fieldContentEn": "محتوا (انگلیسی)",
|
||||
"fieldPublished": "وضعیت انتشار",
|
||||
"commentsTitle": "مدیریت نظرات",
|
||||
"noComments": "نظری یافت نشد",
|
||||
@@ -1107,7 +1107,29 @@
|
||||
"title": "پلنها و قیمتگذاری",
|
||||
"monthlyPrice": "قیمت ماهانه (تومان)",
|
||||
"maxOrders": "سقف سفارش روزانه",
|
||||
"saved": "پلن ذخیره شد"
|
||||
"saved": "پلن ذخیره شد",
|
||||
"active": "فعال",
|
||||
"nameFa": "نام (فارسی)",
|
||||
"nameEn": "نام (انگلیسی)",
|
||||
"sortOrder": "ترتیب",
|
||||
"billable": "قابل پرداخت آنلاین",
|
||||
"limitsTitle": "محدودیتها",
|
||||
"featuresTitle": "امکانات",
|
||||
"allFeatures": "همه امکانات",
|
||||
"allFeaturesNote": "این پلن به همه امکانات (فعلی و آینده) دسترسی دارد.",
|
||||
"save": "ذخیره",
|
||||
"limits": {
|
||||
"maxOrders": "سفارش روزانه",
|
||||
"maxTables": "میزها",
|
||||
"maxTerminals": "پایانه POS",
|
||||
"maxBranches": "شعب",
|
||||
"maxCategories": "دسته منو",
|
||||
"maxItems": "آیتم منو",
|
||||
"maxCustomers": "مشتریان",
|
||||
"maxReportDays": "تاریخچه گزارش (روز)",
|
||||
"maxSms": "پیامک ماهانه",
|
||||
"maxAi3d": "تولید ۳D ماهانه"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "تنظیمات اپلیکیشن",
|
||||
|
||||
Generated
+791
-7
@@ -13,6 +13,11 @@
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.59.20",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-placeholder": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2",
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -427,6 +432,16 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
@@ -1160,6 +1175,12 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1216,6 +1237,422 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
|
||||
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
|
||||
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
|
||||
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
|
||||
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
|
||||
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
|
||||
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
|
||||
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
|
||||
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
|
||||
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
|
||||
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
|
||||
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
|
||||
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
|
||||
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-history": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
|
||||
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
|
||||
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
|
||||
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
|
||||
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
|
||||
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
|
||||
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
|
||||
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz",
|
||||
"integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
|
||||
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
|
||||
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
||||
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-menu": "^1.2.4",
|
||||
"prosemirror-model": "^1.23.0",
|
||||
"prosemirror-schema-basic": "^1.2.3",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-trailing-node": "^3.0.0",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.37.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
|
||||
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^2.27.2",
|
||||
"@tiptap/extension-floating-menu": "^2.27.2",
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-deep-equal": "^3",
|
||||
"use-sync-external-store": "^1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
|
||||
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^2.27.2",
|
||||
"@tiptap/extension-blockquote": "^2.27.2",
|
||||
"@tiptap/extension-bold": "^2.27.2",
|
||||
"@tiptap/extension-bullet-list": "^2.27.2",
|
||||
"@tiptap/extension-code": "^2.27.2",
|
||||
"@tiptap/extension-code-block": "^2.27.2",
|
||||
"@tiptap/extension-document": "^2.27.2",
|
||||
"@tiptap/extension-dropcursor": "^2.27.2",
|
||||
"@tiptap/extension-gapcursor": "^2.27.2",
|
||||
"@tiptap/extension-hard-break": "^2.27.2",
|
||||
"@tiptap/extension-heading": "^2.27.2",
|
||||
"@tiptap/extension-history": "^2.27.2",
|
||||
"@tiptap/extension-horizontal-rule": "^2.27.2",
|
||||
"@tiptap/extension-italic": "^2.27.2",
|
||||
"@tiptap/extension-list-item": "^2.27.2",
|
||||
"@tiptap/extension-ordered-list": "^2.27.2",
|
||||
"@tiptap/extension-paragraph": "^2.27.2",
|
||||
"@tiptap/extension-strike": "^2.27.2",
|
||||
"@tiptap/extension-text": "^2.27.2",
|
||||
"@tiptap/extension-text-style": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
"version": "0.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||
@@ -1223,6 +1660,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
@@ -1237,14 +1696,14 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
|
||||
"integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -1255,12 +1714,18 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||
@@ -1687,7 +2152,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
@@ -2302,6 +2766,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2334,7 +2804,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
@@ -2547,6 +3017,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
@@ -2734,7 +3216,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -3177,7 +3658,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
@@ -4471,6 +4951,31 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
|
||||
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -4522,6 +5027,33 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.1",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4531,6 +5063,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -5000,6 +5538,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
@@ -5373,6 +5917,201 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-collab": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-markdown": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
|
||||
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-menu": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
|
||||
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.0",
|
||||
"prosemirror-commands": "^1.0.0",
|
||||
"prosemirror-history": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.7",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
|
||||
"integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-basic": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-trailing-node": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remirror/core-constants": "3.0.0",
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prosemirror-model": "^1.22.1",
|
||||
"prosemirror-state": "^1.4.2",
|
||||
"prosemirror-view": "^1.33.8"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -5392,6 +6131,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -5688,6 +6436,12 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -6420,6 +7174,15 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tippy.js": {
|
||||
"version": "6.3.7",
|
||||
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -6590,6 +7353,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
@@ -6751,6 +7520,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -6758,6 +7536,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -15,10 +15,15 @@
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.59.20",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-placeholder": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2",
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"axios": "^1.7.7",
|
||||
"date-fns-jalali": "^4.1.0-0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns-jalali": "^4.1.0-0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "14.2.18",
|
||||
"next-intl": "^3.23.5",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
AdminNotificationRow,
|
||||
AdminPlan,
|
||||
AdminStats,
|
||||
PlanLimitsData,
|
||||
GatewayCredentials,
|
||||
PaymentGatewayConfig,
|
||||
PlatformFeature,
|
||||
@@ -44,6 +45,7 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
dir="ltr"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
@@ -130,45 +132,167 @@ function StatCard({ label, value }: { label: string; value: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
|
||||
const PLAN_UNLIMITED = 2147483647;
|
||||
|
||||
const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [
|
||||
{ key: "maxOrdersPerDay", label: "maxOrders" },
|
||||
{ key: "maxTables", label: "maxTables" },
|
||||
{ key: "maxTerminals", label: "maxTerminals" },
|
||||
{ key: "maxBranches", label: "maxBranches" },
|
||||
{ key: "maxMenuCategories", label: "maxCategories" },
|
||||
{ key: "maxMenuItems", label: "maxItems" },
|
||||
{ key: "maxCustomers", label: "maxCustomers" },
|
||||
{ key: "maxReportHistoryDays", label: "maxReportDays" },
|
||||
{ key: "maxSmsPerMonth", label: "maxSms" },
|
||||
{ key: "maxMenuAi3dPerMonth", label: "maxAi3d" },
|
||||
];
|
||||
|
||||
function LimitField({ label, value, onChange }: { label: string; value: number; onChange: (n: number) => void }) {
|
||||
const unlimited = value >= PLAN_UNLIMITED;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<input type="checkbox" checked={unlimited} onChange={(e) => onChange(e.target.checked ? PLAN_UNLIMITED : 0)} />∞
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1 h-8 text-sm"
|
||||
disabled={unlimited}
|
||||
value={unlimited ? "" : value}
|
||||
onChange={(e) => onChange(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
features,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
plan: AdminPlan;
|
||||
features: PlatformFeature[];
|
||||
onSave: (p: AdminPlan) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("admin.plans");
|
||||
const [price, setPrice] = useState(plan.monthlyPriceToman);
|
||||
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
|
||||
const [draft, setDraft] = useState<AdminPlan>(plan);
|
||||
// Re-sync from server after a save/refetch.
|
||||
useEffect(() => { setDraft(plan); }, [plan]);
|
||||
|
||||
// Sync server values if they change (e.g. after successful save + refetch)
|
||||
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
|
||||
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
|
||||
const setField = <K extends keyof AdminPlan>(k: K, v: AdminPlan[K]) =>
|
||||
setDraft((d) => ({ ...d, [k]: v }));
|
||||
const setLimit = (k: keyof PlanLimitsData, v: number) =>
|
||||
setDraft((d) => ({ ...d, limits: { ...d.limits, [k]: v } }));
|
||||
|
||||
const flush = () =>
|
||||
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
|
||||
const wildcard = draft.featureKeys.includes("*");
|
||||
const toggleFeature = (key: string, on: boolean) =>
|
||||
setDraft((d) => {
|
||||
const set = new Set(d.featureKeys.filter((k) => k !== "*"));
|
||||
if (on) set.add(key);
|
||||
else set.delete(key);
|
||||
return { ...d, featureKeys: Array.from(set) };
|
||||
});
|
||||
|
||||
const groups = Array.from(new Set(features.map((f) => f.moduleGroup)));
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{plan.tier}</p>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-base">{draft.displayNameFa || draft.tier}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{draft.tier}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={draft.isActive} onChange={(e) => setField("isActive", e.target.checked)} />
|
||||
{t("active")}
|
||||
</label>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label className="text-sm">
|
||||
{t("nameFa")}
|
||||
<Input className="mt-1 h-8" value={draft.displayNameFa} onChange={(e) => setField("displayNameFa", e.target.value)} dir="rtl" />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("nameEn")}
|
||||
<Input className="mt-1 h-8" value={draft.displayNameEn ?? ""} onChange={(e) => setField("displayNameEn", e.target.value)} dir="ltr" />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("monthlyPrice")}
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(Number(e.target.value))}
|
||||
onBlur={flush}
|
||||
/>
|
||||
<Input type="number" className="mt-1 h-8" value={draft.monthlyPriceToman} onChange={(e) => setField("monthlyPriceToman", Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("maxOrders")}
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1"
|
||||
value={maxOrders}
|
||||
onChange={(e) => setMaxOrders(Number(e.target.value))}
|
||||
onBlur={flush}
|
||||
/>
|
||||
{t("sortOrder")}
|
||||
<Input type="number" className="mt-1 h-8" value={draft.sortOrder} onChange={(e) => setField("sortOrder", Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex w-fit items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={draft.isBillableOnline} onChange={(e) => setField("isBillableOnline", e.target.checked)} />
|
||||
{t("billable")}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold text-muted-foreground">{t("limitsTitle")}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<LimitField key={f.key} label={t(`limits.${f.label}`)} value={draft.limits[f.key] ?? PLAN_UNLIMITED} onChange={(v) => setLimit(f.key, v)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t("featuresTitle")}</p>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={wildcard} onChange={(e) => setField("featureKeys", e.target.checked ? ["*"] : [])} />
|
||||
{t("allFeatures")}
|
||||
</label>
|
||||
</div>
|
||||
{wildcard ? (
|
||||
<p className="text-xs text-muted-foreground">{t("allFeaturesNote")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{groups.map((g) => (
|
||||
<div key={g}>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wide text-muted-foreground">{g}</p>
|
||||
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{features
|
||||
.filter((f) => f.moduleGroup === g)
|
||||
.map((f) => (
|
||||
<label
|
||||
key={f.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-border/60 px-2 py-1.5 text-sm",
|
||||
!f.isEnabledGlobally && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<input type="checkbox" checked={draft.featureKeys.includes(f.key)} onChange={(e) => toggleFeature(f.key, e.target.checked)} />
|
||||
<span className="truncate">{f.displayNameFa}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -181,6 +305,10 @@ export function AdminPlansScreen() {
|
||||
queryKey: ["admin", "plans"],
|
||||
queryFn: () => adminGet<AdminPlan[]>("/api/admin/plans"),
|
||||
});
|
||||
const { data: features = [] } = useQuery({
|
||||
queryKey: ["admin", "features"],
|
||||
queryFn: () => adminGet<PlatformFeature[]>("/api/admin/features"),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (plan: AdminPlan) =>
|
||||
@@ -200,11 +328,19 @@ export function AdminPlansScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const ordered = [...plans].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
|
||||
{ordered.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.tier}
|
||||
plan={plan}
|
||||
features={features}
|
||||
onSave={(p) => save.mutate(p)}
|
||||
saving={save.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -604,11 +740,18 @@ export function AdminIntegrationsScreen() {
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
adminPut<PlatformIntegrations>("/api/admin/integrations", {
|
||||
activePaymentGateway: activeGateway,
|
||||
paymentGateways: gateways.map((g) => ({
|
||||
// Save from `list` (what's rendered/edited), not `gateways` — if the
|
||||
// gateways state hasn't hydrated, `list` falls back to the fetched data,
|
||||
// and edits go through updateGateway which seeds it. This keeps the
|
||||
// rendered, edited, and saved arrays the same source (was dropping
|
||||
// edits like the Zarinpal merchantId when gateways was empty).
|
||||
paymentGateways: list.map((g) => ({
|
||||
id: g.id,
|
||||
isEnabled: g.isEnabled,
|
||||
merchantId: g.id === "zarinpal" ? g.merchantId : undefined,
|
||||
@@ -637,11 +780,14 @@ export function AdminIntegrationsScreen() {
|
||||
});
|
||||
|
||||
const updateGateway = (id: string, patch: Partial<PaymentGatewayConfig>) => {
|
||||
setGateways((prev) => prev.map((g) => (g.id === id ? { ...g, ...patch } : g)));
|
||||
setGateways((prev) => {
|
||||
// Seed from fetched data on the first edit so an edit is never dropped
|
||||
// because the state hadn't hydrated yet.
|
||||
const base = prev.length > 0 ? prev : data?.paymentGateways?.map((g) => ({ ...g })) ?? [];
|
||||
return base.map((g) => (g.id === id ? { ...g, ...patch } : g));
|
||||
});
|
||||
};
|
||||
|
||||
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RichTextEditor } from "@/components/ui/rich-text-editor";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -162,6 +163,7 @@ function BlogToggle({
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
dir="ltr"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
@@ -326,8 +328,14 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
||||
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
|
||||
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
|
||||
</div>
|
||||
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
|
||||
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">{t("fieldContentFa")}</label>
|
||||
<RichTextEditor value={form.contentFa} onChange={setField("contentFa")} dir="rtl" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">{t("fieldContentEn")}</label>
|
||||
<RichTextEditor value={form.contentEn} onChange={setField("contentEn")} dir="ltr" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
|
||||
<span className="text-sm font-medium">{t("fieldPublished")}</span>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Code,
|
||||
Link2,
|
||||
Link2Off,
|
||||
Undo2,
|
||||
Redo2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
dir?: "rtl" | "ltr";
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
/** Headless TipTap rich-text editor producing HTML. Used for long-form content
|
||||
* (blog posts) edited by trusted admins. */
|
||||
export function RichTextEditor({ value, onChange, dir = "rtl", placeholder }: Props) {
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false, // required under Next.js SSR to avoid hydration mismatch
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: "noopener", target: "_blank" } }),
|
||||
Placeholder.configure({ placeholder: placeholder ?? "" }),
|
||||
],
|
||||
content: value || "",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir,
|
||||
class: "meezi-rte-content min-h-44 px-3 py-2 focus:outline-none",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||
});
|
||||
|
||||
// Keep the editor in sync when the external value changes (load existing post / reset).
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const current = editor.getHTML();
|
||||
if (value !== current) {
|
||||
editor.commands.setContent(value || "", false);
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-background" dir={dir}>
|
||||
<Toolbar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
<style>{`
|
||||
.meezi-rte-content { font-size: 0.875rem; line-height: 1.7; }
|
||||
.meezi-rte-content:focus { outline: none; }
|
||||
.meezi-rte-content h1 { font-size: 1.5rem; font-weight: 700; margin: 0.6em 0 0.3em; }
|
||||
.meezi-rte-content h2 { font-size: 1.25rem; font-weight: 700; margin: 0.6em 0 0.3em; }
|
||||
.meezi-rte-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.5em 0 0.25em; }
|
||||
.meezi-rte-content p { margin: 0.4em 0; }
|
||||
.meezi-rte-content ul { list-style: disc; padding-inline-start: 1.5rem; margin: 0.4em 0; }
|
||||
.meezi-rte-content ol { list-style: decimal; padding-inline-start: 1.5rem; margin: 0.4em 0; }
|
||||
.meezi-rte-content blockquote { border-inline-start: 3px solid hsl(var(--primary)); padding-inline-start: 0.75rem; color: hsl(var(--muted-foreground)); margin: 0.5em 0; }
|
||||
.meezi-rte-content a { color: hsl(var(--primary)); text-decoration: underline; }
|
||||
.meezi-rte-content code { background: hsl(var(--muted)); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.85em; }
|
||||
.meezi-rte-content pre { background: hsl(var(--muted)); padding: 0.6rem 0.8rem; border-radius: 8px; overflow-x: auto; }
|
||||
.meezi-rte-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: hsl(var(--muted-foreground)); float: inline-start; height: 0; pointer-events: none; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ editor }: { editor: Editor | null }) {
|
||||
if (!editor) return <div className="h-9 border-b border-border bg-muted/40" />;
|
||||
|
||||
const setLink = () => {
|
||||
const prev = editor.getAttributes("link").href as string | undefined;
|
||||
const url = window.prompt("URL", prev ?? "https://");
|
||||
if (url === null) return;
|
||||
if (url === "") {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/40 px-1.5 py-1">
|
||||
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} title="Bold"><Bold className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} title="Italic"><Italic className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} title="Strikethrough"><Strikethrough className="size-4" /></Btn>
|
||||
<Sep />
|
||||
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })} title="H1"><Heading1 className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })} title="H2"><Heading2 className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })} title="H3"><Heading3 className="size-4" /></Btn>
|
||||
<Sep />
|
||||
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")} title="Bullet list"><List className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")} title="Numbered list"><ListOrdered className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")} title="Quote"><Quote className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive("codeBlock")} title="Code block"><Code className="size-4" /></Btn>
|
||||
<Sep />
|
||||
<Btn onClick={setLink} active={editor.isActive("link")} title="Link"><Link2 className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().unsetLink().run()} active={false} title="Remove link" disabled={!editor.isActive("link")}><Link2Off className="size-4" /></Btn>
|
||||
<Sep />
|
||||
<Btn onClick={() => editor.chain().focus().undo().run()} active={false} title="Undo" disabled={!editor.can().undo()}><Undo2 className="size-4" /></Btn>
|
||||
<Btn onClick={() => editor.chain().focus().redo().run()} active={false} title="Redo" disabled={!editor.can().redo()}><Redo2 className="size-4" /></Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Btn({
|
||||
onClick,
|
||||
active,
|
||||
title,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active: boolean;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground disabled:opacity-40",
|
||||
active && "bg-primary/15 text-primary"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Sep() {
|
||||
return <span className="mx-0.5 h-5 w-px bg-border" />;
|
||||
}
|
||||
@@ -8,11 +8,15 @@ export type AdminStats = {
|
||||
|
||||
export type PlanLimitsData = {
|
||||
maxOrdersPerDay: number;
|
||||
maxTables: number;
|
||||
maxTerminals: number;
|
||||
maxCustomers: number;
|
||||
maxSmsPerMonth: number;
|
||||
maxBranches: number;
|
||||
maxReportHistoryDays: number;
|
||||
maxMenuCategories: number;
|
||||
maxMenuItems: number;
|
||||
maxMenuAi3dPerMonth: number;
|
||||
};
|
||||
|
||||
export type AdminPlan = {
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
"saved": "تم الحفظ",
|
||||
"errorGeneric": "حدث خطأ. حاول مرة أخرى."
|
||||
},
|
||||
"errors": {
|
||||
"planLimit": "وصلت إلى حد الخطة",
|
||||
"notFound": "غير موجود",
|
||||
"unauthorized": "غير مصرح",
|
||||
"network": "خطأ في الاتصال",
|
||||
"generic": "حدث خطأ. حاول مرة أخرى.",
|
||||
"OFFLINE_UNAVAILABLE": "يتطلب هذا الإجراء اتصالاً بالإنترنت. يرجى المحاولة بعد عودة الاتصال."
|
||||
},
|
||||
"brand": {
|
||||
"name": "ميزي"
|
||||
},
|
||||
@@ -243,6 +251,7 @@
|
||||
"void": "إلغاء",
|
||||
"voidItem": "إلغاء الصنف",
|
||||
"voided": "ملغى",
|
||||
"itemNotePlaceholder": "ملاحظة للمطبخ/البار (اختياري)",
|
||||
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟",
|
||||
"voidError": "تعذر إلغاء الصنف",
|
||||
"transferTable": "نقل الطاولة",
|
||||
@@ -372,7 +381,10 @@
|
||||
"duplicatePhone": "رقم الجوال مسجل مسبقاً.",
|
||||
"generic": "تعذر الحفظ. حاول مرة أخرى."
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleted": "تم حذف العميل",
|
||||
"deleteConfirmTitle": "حذف العميل",
|
||||
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟"
|
||||
},
|
||||
"coupons": {
|
||||
"title": "القسائم",
|
||||
@@ -388,7 +400,10 @@
|
||||
"FixedAmount": "مبلغ ثابت",
|
||||
"FreeItem": "عنصر مجاني"
|
||||
},
|
||||
"noCoupons": "لا توجد قسائم"
|
||||
"noCoupons": "لا توجد قسائم",
|
||||
"deleted": "تم حذف القسيمة",
|
||||
"deleteConfirmTitle": "حذف القسيمة",
|
||||
"deleteConfirmDesc": "هل أنت متأكد من حذف القسيمة «{code}»؟"
|
||||
},
|
||||
"hr": {
|
||||
"title": "الموارد البشرية",
|
||||
@@ -397,7 +412,8 @@
|
||||
"leave": "الإجازة",
|
||||
"payroll": "الرواتب",
|
||||
"access": "صلاحيات الفروع",
|
||||
"credentials": "بيانات الدخول"
|
||||
"credentials": "بيانات الدخول",
|
||||
"team": "الموظفون"
|
||||
},
|
||||
"myAttendance": "حضوري",
|
||||
"clockIn": "تسجيل دخول",
|
||||
@@ -422,6 +438,33 @@
|
||||
"saved": "تم حفظ بيانات الدخول.",
|
||||
"removed": "تم حذف بيانات الدخول.",
|
||||
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
|
||||
},
|
||||
"addEmployee": "إضافة موظف",
|
||||
"noEmployees": "لا يوجد موظفون بعد.",
|
||||
"employeeCreated": "تمت إضافة الموظف",
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء",
|
||||
"fields": {
|
||||
"name": "الاسم",
|
||||
"phone": "الجوال",
|
||||
"role": "الدور",
|
||||
"branch": "الفرع",
|
||||
"branchOptional": "اختياري",
|
||||
"noBranch": "بدون فرع",
|
||||
"baseSalary": "الراتب الأساسي (تومان)",
|
||||
"optional": "اختياري",
|
||||
"enableLogin": "إنشاء اسم مستخدم وكلمة مرور",
|
||||
"username": "اسم المستخدم",
|
||||
"password": "كلمة المرور",
|
||||
"passwordHint": "8 أحرف على الأقل"
|
||||
},
|
||||
"roles": {
|
||||
"Owner": "المالك",
|
||||
"Manager": "مدير",
|
||||
"Cashier": "أمين الصندوق",
|
||||
"Waiter": "نادل",
|
||||
"Chef": "طاهٍ",
|
||||
"Delivery": "موصّل"
|
||||
}
|
||||
},
|
||||
"reviews": {
|
||||
@@ -735,7 +778,13 @@
|
||||
"addItemSuccess": "تمت إضافة الصنف",
|
||||
"updateItemSuccess": "تم تحديث الصنف",
|
||||
"addCategorySuccess": "تمت إضافة الفئة",
|
||||
"updateCategorySuccess": "تم تحديث الفئة"
|
||||
"updateCategorySuccess": "تم تحديث الفئة",
|
||||
"deleteItemConfirmTitle": "حذف الصنف",
|
||||
"deleteItemConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"deleteItemSuccess": "تم حذف الصنف",
|
||||
"deleteCategoryConfirmTitle": "حذف الفئة",
|
||||
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
|
||||
"deleteCategorySuccess": "تم حذف الفئة"
|
||||
},
|
||||
"branchMenu": {
|
||||
"title": "قائمة الفرع",
|
||||
@@ -829,7 +878,10 @@
|
||||
"purchasesThisMonth": "مشتريات المواد هذا الشهر",
|
||||
"purchaseCount": "{count} عملية شراء",
|
||||
"viewInExpenses": "عرض في المصروفات",
|
||||
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع."
|
||||
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع.",
|
||||
"deleted": "تم حذف المادة",
|
||||
"deleteConfirmTitle": "حذف المادة",
|
||||
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع."
|
||||
},
|
||||
"qr": {
|
||||
"brand": "ميزي",
|
||||
@@ -856,6 +908,7 @@
|
||||
"orderHint": "سيقوم الموظفون بتحضير طلبك قريباً",
|
||||
"guestName": "اسمك (اختياري)",
|
||||
"guestPhone": "الجوال (اختياري)",
|
||||
"itemNote": "ملاحظة (مثلاً بدون طماطم، سكر أقل)",
|
||||
"addMoreItems": "إضافة المزيد",
|
||||
"orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.",
|
||||
"rateLimited": "طلبات كثيرة — انتظر بضع دقائق",
|
||||
@@ -943,7 +996,10 @@
|
||||
"Cancelled": "ملغى",
|
||||
"Seated": "جالس",
|
||||
"Completed": "مكتمل"
|
||||
}
|
||||
},
|
||||
"deleted": "تم حذف الحجز",
|
||||
"deleteConfirmTitle": "حذف الحجز",
|
||||
"deleteConfirmDesc": "هل أنت متأكد من حذف حجز «{name}»؟"
|
||||
},
|
||||
"branchesPage": {
|
||||
"title": "الفروع",
|
||||
@@ -1020,7 +1076,18 @@
|
||||
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
|
||||
"payTotal": "ادفع {total}",
|
||||
"redirecting": "جارٍ التحويل إلى البوابة...",
|
||||
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
|
||||
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى.",
|
||||
"queuedNotice": "لديك اشتراك نشط بالفعل. ستتم إضافة هذا الشراء إلى قائمة الانتظار وسيبدأ في {date}."
|
||||
},
|
||||
"queued": {
|
||||
"title": "الاشتراكات في قائمة الانتظار",
|
||||
"subtitle": "تبدأ تلقائيًا عند انتهاء اشتراكك الحالي.",
|
||||
"months": "{count} أشهر",
|
||||
"window": "من {from} إلى {to}",
|
||||
"cancel": "إلغاء",
|
||||
"cancelled": "تم إلغاء الاشتراك في قائمة الانتظار",
|
||||
"cancelConfirmTitle": "إلغاء الاشتراك المجدول",
|
||||
"cancelConfirmDesc": "إلغاء اشتراك {plan} المقرر أن يبدأ في {from}؟ لن يتأثر اشتراكك الحالي."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1359,12 +1426,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"planLimit": "وصلت إلى حد الخطة",
|
||||
"notFound": "غير موجود",
|
||||
"unauthorized": "غير مصرح",
|
||||
"network": "خطأ في الاتصال"
|
||||
},
|
||||
"discoverPublic": {
|
||||
"brand": "ميزي",
|
||||
"title": "اكتشاف المقاهي",
|
||||
@@ -1511,5 +1572,9 @@
|
||||
"mid": "میانه",
|
||||
"premium": "پریمیوم"
|
||||
}
|
||||
},
|
||||
"cafePublicProfile": {
|
||||
"showOnKoja": "العرض على كوجا",
|
||||
"showOnKojaHint": "إدراج مقهاك في دليل كوجا العام (koja.meezi.ir). مفعّل افتراضيًا."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
"saved": "Saved",
|
||||
"errorGeneric": "Something went wrong. Please try again."
|
||||
},
|
||||
"errors": {
|
||||
"planLimit": "Plan limit reached. Please upgrade.",
|
||||
"notFound": "Not found",
|
||||
"unauthorized": "Unauthorized",
|
||||
"network": "Network error",
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"OFFLINE_UNAVAILABLE": "This action needs an internet connection. Please try again once you are back online."
|
||||
},
|
||||
"brand": {
|
||||
"name": "Meezi"
|
||||
},
|
||||
@@ -262,6 +270,7 @@
|
||||
"void": "Void",
|
||||
"voidItem": "Void item",
|
||||
"voided": "Voided",
|
||||
"itemNotePlaceholder": "Note for kitchen/bar (optional)",
|
||||
"confirmVoid": "Are you sure you want to void this item?",
|
||||
"voidError": "Could not void item",
|
||||
"transferTable": "Transfer table",
|
||||
@@ -391,7 +400,10 @@
|
||||
"duplicatePhone": "This phone number is already registered.",
|
||||
"generic": "Could not save. Please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleted": "Customer deleted",
|
||||
"deleteConfirmTitle": "Delete customer",
|
||||
"deleteConfirmDesc": "Delete “{name}”?"
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Coupons",
|
||||
@@ -407,7 +419,10 @@
|
||||
"FixedAmount": "Fixed amount",
|
||||
"FreeItem": "Free item"
|
||||
},
|
||||
"noCoupons": "No coupons yet"
|
||||
"noCoupons": "No coupons yet",
|
||||
"deleted": "Coupon deleted",
|
||||
"deleteConfirmTitle": "Delete coupon",
|
||||
"deleteConfirmDesc": "Delete coupon “{code}”?"
|
||||
},
|
||||
"hr": {
|
||||
"title": "Human resources",
|
||||
@@ -416,7 +431,8 @@
|
||||
"leave": "Leave",
|
||||
"payroll": "Payroll",
|
||||
"access": "Branch access",
|
||||
"credentials": "Login credentials"
|
||||
"credentials": "Login credentials",
|
||||
"team": "Team"
|
||||
},
|
||||
"myAttendance": "My attendance",
|
||||
"clockIn": "Clock in",
|
||||
@@ -441,6 +457,33 @@
|
||||
"saved": "Credentials saved.",
|
||||
"removed": "Credentials removed.",
|
||||
"usernameTaken": "This username is already taken."
|
||||
},
|
||||
"addEmployee": "Add employee",
|
||||
"noEmployees": "No employees yet.",
|
||||
"employeeCreated": "Employee added",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"phone": "Mobile",
|
||||
"role": "Role",
|
||||
"branch": "Branch",
|
||||
"branchOptional": "optional",
|
||||
"noBranch": "No branch",
|
||||
"baseSalary": "Base salary (Toman)",
|
||||
"optional": "optional",
|
||||
"enableLogin": "Create username & password",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"passwordHint": "At least 8 characters"
|
||||
},
|
||||
"roles": {
|
||||
"Owner": "Owner",
|
||||
"Manager": "Manager",
|
||||
"Cashier": "Cashier",
|
||||
"Waiter": "Waiter",
|
||||
"Chef": "Chef",
|
||||
"Delivery": "Delivery"
|
||||
}
|
||||
},
|
||||
"reviews": {
|
||||
@@ -778,7 +821,13 @@
|
||||
"addItemSuccess": "Item added",
|
||||
"updateItemSuccess": "Item updated",
|
||||
"addCategorySuccess": "Category added",
|
||||
"updateCategorySuccess": "Category updated"
|
||||
"updateCategorySuccess": "Category updated",
|
||||
"deleteItemConfirmTitle": "Delete item",
|
||||
"deleteItemConfirmDesc": "Are you sure you want to delete “{name}”? This can't be undone.",
|
||||
"deleteItemSuccess": "Item deleted",
|
||||
"deleteCategoryConfirmTitle": "Delete category",
|
||||
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
|
||||
"deleteCategorySuccess": "Category deleted"
|
||||
},
|
||||
"branchMenu": {
|
||||
"title": "Branch Menu",
|
||||
@@ -898,7 +947,10 @@
|
||||
"purchasesThisMonth": "Material purchases this month",
|
||||
"purchaseCount": "{count} purchases",
|
||||
"viewInExpenses": "View in expenses",
|
||||
"selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases."
|
||||
"selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases.",
|
||||
"deleted": "Material deleted",
|
||||
"deleteConfirmTitle": "Delete material",
|
||||
"deleteConfirmDesc": "Delete “{name}”? This can’t be undone."
|
||||
},
|
||||
"qr": {
|
||||
"brand": "Meezi",
|
||||
@@ -925,6 +977,7 @@
|
||||
"orderHint": "Staff will prepare your order shortly",
|
||||
"guestName": "Your name (optional)",
|
||||
"guestPhone": "Mobile (optional)",
|
||||
"itemNote": "Note (e.g. no tomato, less sugar)",
|
||||
"addMoreItems": "Add more items",
|
||||
"orderError": "Could not place order. Try again.",
|
||||
"rateLimited": "Too many requests — please wait a few minutes",
|
||||
@@ -1013,7 +1066,10 @@
|
||||
"Cancelled": "Cancelled",
|
||||
"Seated": "Seated",
|
||||
"Completed": "Completed"
|
||||
}
|
||||
},
|
||||
"deleted": "Reservation deleted",
|
||||
"deleteConfirmTitle": "Delete reservation",
|
||||
"deleteConfirmDesc": "Delete the reservation for “{name}”?"
|
||||
},
|
||||
"branchesPage": {
|
||||
"title": "Branches",
|
||||
@@ -1092,7 +1148,18 @@
|
||||
"secureNote": "Payment is processed through a secure bank gateway.",
|
||||
"payTotal": "Pay {total}",
|
||||
"redirecting": "Redirecting to gateway...",
|
||||
"paymentFailed": "Payment failed. Please try again."
|
||||
"paymentFailed": "Payment failed. Please try again.",
|
||||
"queuedNotice": "You already have an active subscription. This purchase will be queued and start on {date}."
|
||||
},
|
||||
"queued": {
|
||||
"title": "Queued subscriptions",
|
||||
"subtitle": "These start automatically when your current subscription ends.",
|
||||
"months": "{count} months",
|
||||
"window": "From {from} to {to}",
|
||||
"cancel": "Cancel",
|
||||
"cancelled": "Queued subscription cancelled",
|
||||
"cancelConfirmTitle": "Cancel queued subscription",
|
||||
"cancelConfirmDesc": "Cancel the {plan} subscription scheduled to start on {from}? Your current subscription is unaffected."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1441,12 +1508,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"planLimit": "Plan limit reached. Please upgrade.",
|
||||
"notFound": "Not found",
|
||||
"unauthorized": "Unauthorized",
|
||||
"network": "Network error"
|
||||
},
|
||||
"discoverPublic": {
|
||||
"brand": "Meezi",
|
||||
"title": "Discover cafés",
|
||||
@@ -1551,7 +1612,9 @@
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Save failed",
|
||||
"loading": "Loading…"
|
||||
"loading": "Loading…",
|
||||
"showOnKoja": "Show on Koja",
|
||||
"showOnKojaHint": "List your café in the public Koja directory (koja.meezi.ir). On by default."
|
||||
},
|
||||
"discoverProfile": {
|
||||
"sections": {
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
"saved": "ذخیره شد",
|
||||
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
|
||||
},
|
||||
"errors": {
|
||||
"planLimit": "به سقف پلن رسیدهاید. برای ادامه ارتقا دهید",
|
||||
"notFound": "یافت نشد",
|
||||
"unauthorized": "دسترسی ندارید",
|
||||
"network": "خطای ارتباط با سرور",
|
||||
"generic": "خطایی رخ داد. دوباره تلاش کنید.",
|
||||
"OFFLINE_UNAVAILABLE": "برای این کار به اینترنت نیاز است. لطفاً پس از اتصال دوباره تلاش کنید."
|
||||
},
|
||||
"brand": {
|
||||
"name": "میزی"
|
||||
},
|
||||
@@ -262,6 +270,7 @@
|
||||
"void": "ابطال",
|
||||
"voidItem": "ابطال آیتم",
|
||||
"voided": "ابطال شده",
|
||||
"itemNotePlaceholder": "یادداشت برای آشپزخانه/بار (اختیاری)",
|
||||
"confirmVoid": "آیا مطمئن هستید که میخواهید این آیتم را ابطال کنید؟",
|
||||
"voidError": "خطا در ابطال آیتم",
|
||||
"transferTable": "انتقال میز",
|
||||
@@ -391,7 +400,10 @@
|
||||
"duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.",
|
||||
"generic": "ذخیره انجام نشد. دوباره تلاش کنید."
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleted": "مشتری حذف شد",
|
||||
"deleteConfirmTitle": "حذف مشتری",
|
||||
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟"
|
||||
},
|
||||
"coupons": {
|
||||
"title": "کوپنها",
|
||||
@@ -407,7 +419,10 @@
|
||||
"FixedAmount": "مبلغ ثابت",
|
||||
"FreeItem": "آیتم رایگان"
|
||||
},
|
||||
"noCoupons": "کوپنی ثبت نشده"
|
||||
"noCoupons": "کوپنی ثبت نشده",
|
||||
"deleted": "کوپن حذف شد",
|
||||
"deleteConfirmTitle": "حذف کوپن",
|
||||
"deleteConfirmDesc": "آیا از حذف کوپن «{code}» مطمئن هستید؟"
|
||||
},
|
||||
"hr": {
|
||||
"title": "منابع انسانی",
|
||||
@@ -416,7 +431,8 @@
|
||||
"leave": "مرخصی",
|
||||
"payroll": "حقوق",
|
||||
"access": "دسترسی شعب",
|
||||
"credentials": "رمز ورود"
|
||||
"credentials": "رمز ورود",
|
||||
"team": "کارکنان"
|
||||
},
|
||||
"myAttendance": "حضور من",
|
||||
"clockIn": "ورود",
|
||||
@@ -441,6 +457,33 @@
|
||||
"saved": "رمز ورود ذخیره شد.",
|
||||
"removed": "رمز ورود حذف شد.",
|
||||
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
|
||||
},
|
||||
"addEmployee": "افزودن کارمند",
|
||||
"noEmployees": "هنوز کارمندی ثبت نشده است.",
|
||||
"employeeCreated": "کارمند اضافه شد",
|
||||
"save": "ذخیره",
|
||||
"cancel": "انصراف",
|
||||
"fields": {
|
||||
"name": "نام",
|
||||
"phone": "موبایل",
|
||||
"role": "نقش",
|
||||
"branch": "شعبه",
|
||||
"branchOptional": "اختیاری",
|
||||
"noBranch": "بدون شعبه",
|
||||
"baseSalary": "حقوق پایه (تومان)",
|
||||
"optional": "اختیاری",
|
||||
"enableLogin": "ایجاد نام کاربری و رمز ورود",
|
||||
"username": "نام کاربری",
|
||||
"password": "رمز عبور",
|
||||
"passwordHint": "حداقل ۸ کاراکتر"
|
||||
},
|
||||
"roles": {
|
||||
"Owner": "مالک",
|
||||
"Manager": "مدیر",
|
||||
"Cashier": "صندوقدار",
|
||||
"Waiter": "گارسون",
|
||||
"Chef": "آشپز",
|
||||
"Delivery": "پیک"
|
||||
}
|
||||
},
|
||||
"reviews": {
|
||||
@@ -778,7 +821,13 @@
|
||||
"addItemSuccess": "آیتم اضافه شد",
|
||||
"updateItemSuccess": "آیتم بهروز شد",
|
||||
"addCategorySuccess": "دسته اضافه شد",
|
||||
"updateCategorySuccess": "دسته بهروز شد"
|
||||
"updateCategorySuccess": "دسته بهروز شد",
|
||||
"deleteItemConfirmTitle": "حذف آیتم",
|
||||
"deleteItemConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.",
|
||||
"deleteItemSuccess": "آیتم حذف شد",
|
||||
"deleteCategoryConfirmTitle": "حذف دستهبندی",
|
||||
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
|
||||
"deleteCategorySuccess": "دسته حذف شد"
|
||||
},
|
||||
"branchMenu": {
|
||||
"title": "منوی شعبه",
|
||||
@@ -898,7 +947,10 @@
|
||||
"purchasesThisMonth": "خرید مواد این ماه",
|
||||
"purchaseCount": "{count} خرید",
|
||||
"viewInExpenses": "مشاهده در هزینهها",
|
||||
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید."
|
||||
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید.",
|
||||
"deleted": "ماده حذف شد",
|
||||
"deleteConfirmTitle": "حذف ماده",
|
||||
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست."
|
||||
},
|
||||
"qr": {
|
||||
"brand": "میزی",
|
||||
@@ -925,6 +977,7 @@
|
||||
"orderHint": "کارکنان به زودی سفارش شما را آماده میکنند",
|
||||
"guestName": "نام شما (اختیاری)",
|
||||
"guestPhone": "شماره موبایل (اختیاری)",
|
||||
"itemNote": "یادداشت (مثلاً بدون گوجه، کمشکر)",
|
||||
"addMoreItems": "افزودن آیتم دیگر",
|
||||
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
|
||||
"orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.",
|
||||
@@ -1014,7 +1067,10 @@
|
||||
"Cancelled": "لغو شده",
|
||||
"Seated": "نشسته",
|
||||
"Completed": "انجام شده"
|
||||
}
|
||||
},
|
||||
"deleted": "رزرو حذف شد",
|
||||
"deleteConfirmTitle": "حذف رزرو",
|
||||
"deleteConfirmDesc": "آیا از حذف رزرو «{name}» مطمئن هستید؟"
|
||||
},
|
||||
"branchesPage": {
|
||||
"title": "شعب",
|
||||
@@ -1093,7 +1149,18 @@
|
||||
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام میشود.",
|
||||
"payTotal": "پرداخت {total}",
|
||||
"redirecting": "در حال انتقال به درگاه...",
|
||||
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید."
|
||||
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید.",
|
||||
"queuedNotice": "شما اشتراک فعالی دارید. این خرید در صف قرار میگیرد و از {date} آغاز میشود."
|
||||
},
|
||||
"queued": {
|
||||
"title": "اشتراکهای در صف",
|
||||
"subtitle": "این اشتراکها پس از پایان اشتراک فعلی بهصورت خودکار فعال میشوند.",
|
||||
"months": "{count} ماه",
|
||||
"window": "از {from} تا {to}",
|
||||
"cancel": "لغو",
|
||||
"cancelled": "اشتراک در صف لغو شد",
|
||||
"cancelConfirmTitle": "لغو اشتراک در صف",
|
||||
"cancelConfirmDesc": "اشتراک {plan} که قرار بود از {from} آغاز شود لغو شود؟ اشتراک فعلی شما دستنخورده میماند."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1442,12 +1509,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"planLimit": "به سقف پلن رسیدهاید. برای ادامه ارتقا دهید",
|
||||
"notFound": "یافت نشد",
|
||||
"unauthorized": "دسترسی ندارید",
|
||||
"network": "خطای ارتباط با سرور"
|
||||
},
|
||||
"discoverPublic": {
|
||||
"brand": "میزی",
|
||||
"title": "کافهیاب",
|
||||
@@ -1552,7 +1613,9 @@
|
||||
"save": "ذخیره",
|
||||
"saved": "ذخیره شد",
|
||||
"saveFailed": "ذخیره ناموفق بود",
|
||||
"loading": "در حال بارگذاری…"
|
||||
"loading": "در حال بارگذاری…",
|
||||
"showOnKoja": "نمایش در کوجا",
|
||||
"showOnKojaHint": "کافه شما در فهرست عمومی کوجا (koja.meezi.ir) نمایش داده شود. پیشفرض روشن است."
|
||||
},
|
||||
"discoverProfile": {
|
||||
"sections": {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { RouteGuard } from "@/components/auth/route-guard";
|
||||
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
|
||||
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -20,6 +21,7 @@ export default function DashboardLayout({
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||
useOfflineSync(); // register online/offline listeners + load queue count
|
||||
useOrderAlerts(); // global sound+toast alert for guest QR-menu orders, any screen
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for Zustand to finish reading localStorage before deciding to redirect.
|
||||
|
||||
@@ -3,24 +3,29 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus } from "lucide-react";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
|
||||
import type { Coupon, CouponType } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
|
||||
export function CouponsScreen() {
|
||||
const t = useTranslations("coupons");
|
||||
const tCommon = useTranslations("common");
|
||||
const apiError = useApiError();
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Coupon | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [type, setType] = useState<CouponType>("Percentage");
|
||||
const [value, setValue] = useState("10");
|
||||
@@ -47,6 +52,16 @@ export function CouponsScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCoupon = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/coupons/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["coupons", cafeId] });
|
||||
setDeleteTarget(null);
|
||||
notify.success(t("deleted"));
|
||||
},
|
||||
onError: (err) => notify.error(apiError(err)),
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
@@ -132,11 +147,34 @@ export function CouponsScreen() {
|
||||
{t("usage")}: {formatNumber(c.usedCount)}
|
||||
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
|
||||
</p>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() => setDeleteTarget(c)}
|
||||
>
|
||||
<Trash2 className="me-1.5 size-4" />
|
||||
{tCommon("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteTarget(null);
|
||||
}}
|
||||
title={t("deleteConfirmTitle")}
|
||||
description={deleteTarget ? t("deleteConfirmDesc", { code: deleteTarget.code }) : undefined}
|
||||
busy={deleteCoupon.isPending}
|
||||
onConfirm={() => deleteTarget && deleteCoupon.mutate(deleteTarget.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Pencil, Search } from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
|
||||
import { apiDelete, apiGet } from "@/lib/api/client";
|
||||
import type { Customer } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
|
||||
|
||||
export function CrmScreen() {
|
||||
const t = useTranslations("crm");
|
||||
const tCommon = useTranslations("common");
|
||||
const apiError = useApiError();
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -26,6 +30,7 @@ export function CrmScreen() {
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
|
||||
|
||||
const { data: customers = [], isLoading } = useQuery({
|
||||
queryKey: ["customers", cafeId, debouncedSearch],
|
||||
@@ -46,6 +51,16 @@ export function CrmScreen() {
|
||||
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
|
||||
};
|
||||
|
||||
const deleteCustomer = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/customers/${id}`),
|
||||
onSuccess: () => {
|
||||
refreshCustomers();
|
||||
setDeleteTarget(null);
|
||||
notify.success(t("deleted"));
|
||||
},
|
||||
onError: (err) => notify.error(apiError(err)),
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
@@ -104,21 +119,43 @@ export function CrmScreen() {
|
||||
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
className="flex-1"
|
||||
onClick={() => openWizard("edit", c)}
|
||||
>
|
||||
<Pencil className="me-1 h-3.5 w-3.5" />
|
||||
{tCommon("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
aria-label={tCommon("delete")}
|
||||
onClick={() => setDeleteTarget(c)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteTarget(null);
|
||||
}}
|
||||
title={t("deleteConfirmTitle")}
|
||||
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
|
||||
busy={deleteCustomer.isPending}
|
||||
onConfirm={() => deleteTarget && deleteCustomer.mutate(deleteTarget.id)}
|
||||
/>
|
||||
|
||||
<CustomerWizard
|
||||
open={wizardOpen}
|
||||
mode={wizardMode}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -26,6 +28,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const qc = useQueryClient();
|
||||
const apiError = useApiError();
|
||||
const [done, setDone] = useState(false);
|
||||
const [summary, setSummary] = useState<DemoSeedResult | null>(null);
|
||||
|
||||
@@ -39,6 +42,9 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
notify.error(apiError(err));
|
||||
},
|
||||
});
|
||||
|
||||
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateCafePublicProfile,
|
||||
uploadGalleryPhoto,
|
||||
type CafeProfileEdit,
|
||||
type UpdateCafeProfilePayload,
|
||||
} from "@/lib/api/cafe-public-profile";
|
||||
import type { WorkingHours } from "@/lib/api/public-discover";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
@@ -42,6 +43,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
||||
const [instagram, setInstagram] = useState<string>("");
|
||||
const [website, setWebsite] = useState<string>("");
|
||||
const [hours, setHours] = useState<WorkingHours>(emptyHours());
|
||||
const [showOnKoja, setShowOnKoja] = useState(true);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Populate local state once we get server data
|
||||
@@ -50,17 +52,20 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
||||
setInstagram(profile.instagramHandle ?? "");
|
||||
setWebsite(profile.websiteUrl ?? "");
|
||||
setHours(profile.workingHours ?? emptyHours());
|
||||
setShowOnKoja(profile.showOnKoja ?? true);
|
||||
setInitialized(true);
|
||||
}
|
||||
|
||||
// ── Save info/social/hours ────────────────────────────────────────────────
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (override?: Partial<UpdateCafeProfilePayload>) =>
|
||||
updateCafePublicProfile(cafeId, {
|
||||
description,
|
||||
instagramHandle: instagram || null,
|
||||
websiteUrl: website || null,
|
||||
workingHours: hours,
|
||||
showOnKoja,
|
||||
...override,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["cafe-public-profile", cafeId], data);
|
||||
@@ -157,6 +162,23 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
||||
{tab === "info" && (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/40 px-3 py-2.5">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{t("showOnKoja")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("showOnKojaHint")}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnKoja}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setShowOnKoja(v);
|
||||
// Persist immediately (pass the new value to avoid stale state).
|
||||
saveMutation.mutate({ showOnKoja: v });
|
||||
}}
|
||||
className="h-5 w-5 shrink-0 cursor-pointer accent-[#0F6E56]"
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("description")}</Label>
|
||||
<textarea
|
||||
@@ -167,7 +189,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
||||
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
|
||||
/>
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -276,7 +298,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -307,7 +329,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
|
||||
/** Roles that can be created from the dashboard (Owner is created only at signup). */
|
||||
const ALL_ROLES = ["Manager", "Cashier", "Waiter", "Chef", "Delivery"] as const;
|
||||
type Role = (typeof ALL_ROLES)[number];
|
||||
|
||||
type Branch = { id: string; name: string };
|
||||
|
||||
const selectClass =
|
||||
"h-9 w-full rounded-lg border border-border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/30";
|
||||
|
||||
export function AddEmployeeForm({
|
||||
cafeId,
|
||||
canAddManager,
|
||||
onClose,
|
||||
}: {
|
||||
cafeId: string;
|
||||
canAddManager: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations("hr");
|
||||
const apiError = useApiError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [role, setRole] = useState<Role>("Waiter");
|
||||
const [branchId, setBranchId] = useState("");
|
||||
const [baseSalary, setBaseSalary] = useState("");
|
||||
const [withLogin, setWithLogin] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const roles = canAddManager ? ALL_ROLES : ALL_ROLES.filter((r) => r !== "Manager");
|
||||
|
||||
const credsValid = !withLogin || (username.trim().length > 0 && password.length >= 8);
|
||||
const canSubmit = name.trim().length > 0 && phone.trim().length > 0 && credsValid;
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/employees`, {
|
||||
name: name.trim(),
|
||||
phone: phone.trim(),
|
||||
role,
|
||||
branchId: branchId || null,
|
||||
baseSalary: baseSalary ? Number(baseSalary) : null,
|
||||
username: withLogin ? username.trim() : null,
|
||||
password: withLogin ? password : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
notify.success(t("employeeCreated"));
|
||||
queryClient.invalidateQueries({ queryKey: ["employees", cafeId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => notify.error(apiError(err)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold">{t("addEmployee")}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<LabeledField label={t("fields.name")} htmlFor="emp-name">
|
||||
<Input id="emp-name" value={name} onChange={(e) => setName(e.target.value)} dir="rtl" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("fields.phone")} htmlFor="emp-phone">
|
||||
<Input id="emp-phone" value={phone} onChange={(e) => setPhone(e.target.value)} dir="ltr" placeholder="09xxxxxxxxx" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("fields.role")} htmlFor="emp-role">
|
||||
<select id="emp-role" className={selectClass} value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||
{roles.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`roles.${r}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("fields.branch")} htmlFor="emp-branch" hint={t("fields.branchOptional")}>
|
||||
<select id="emp-branch" className={selectClass} value={branchId} onChange={(e) => setBranchId(e.target.value)}>
|
||||
<option value="">{t("fields.noBranch")}</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("fields.baseSalary")} htmlFor="emp-salary" hint={t("fields.optional")}>
|
||||
<Input
|
||||
id="emp-salary"
|
||||
value={baseSalary}
|
||||
onChange={(e) => setBaseSalary(e.target.value.replace(/[^\d]/g, ""))}
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={withLogin} onChange={(e) => setWithLogin(e.target.checked)} className="size-4" />
|
||||
{t("fields.enableLogin")}
|
||||
</label>
|
||||
|
||||
{withLogin && (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<LabeledField label={t("fields.username")} htmlFor="emp-username">
|
||||
<Input id="emp-username" value={username} onChange={(e) => setUsername(e.target.value)} dir="ltr" autoComplete="off" />
|
||||
</LabeledField>
|
||||
<LabeledField label={t("fields.password")} htmlFor="emp-password" hint={t("fields.passwordHint")}>
|
||||
<Input id="emp-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} dir="ltr" autoComplete="new-password" />
|
||||
</LabeledField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={createMut.isPending}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => createMut.mutate()} disabled={!canSubmit || createMut.isPending}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
||||
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
|
||||
import { AddEmployeeForm } from "@/components/hr/add-employee-form";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
@@ -48,7 +50,7 @@ interface Salary {
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
|
||||
type Tab = "team" | "attendance" | "leave" | "payroll" | "access" | "credentials";
|
||||
|
||||
export function HrScreen() {
|
||||
const t = useTranslations("hr");
|
||||
@@ -57,7 +59,8 @@ export function HrScreen() {
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const canManageAccess = role === "Owner" || role === "Manager";
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<Tab>("attendance");
|
||||
const [tab, setTab] = useState<Tab>("team");
|
||||
const [addingEmployee, setAddingEmployee] = useState(false);
|
||||
const [monthYear, setMonthYear] = useState(
|
||||
new Date().toISOString().slice(0, 7)
|
||||
);
|
||||
@@ -123,7 +126,7 @@ export function HrScreen() {
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
|
||||
{((["team", "attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
|
||||
(key) => (key !== "access" && key !== "credentials") || canManageAccess
|
||||
)).map((key) => (
|
||||
<Button
|
||||
@@ -137,6 +140,50 @@ export function HrScreen() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "team" && (
|
||||
<div className="space-y-4">
|
||||
{canManageAccess && (
|
||||
<div className="flex justify-end">
|
||||
{addingEmployee ? null : (
|
||||
<Button size="sm" onClick={() => setAddingEmployee(true)}>
|
||||
<UserPlus className="me-1.5 size-4" />
|
||||
{t("addEmployee")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addingEmployee && (
|
||||
<AddEmployeeForm
|
||||
cafeId={cafeId}
|
||||
canAddManager={role === "Owner"}
|
||||
onClose={() => setAddingEmployee(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{employees.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noEmployees")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{employees.map((e) => (
|
||||
<Card key={e.id}>
|
||||
<CardContent className="space-y-1 pt-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium">{e.name}</p>
|
||||
<Badge variant="outline">{t(`roles.${e.role}`)}</Badge>
|
||||
</div>
|
||||
<p dir="ltr" className="text-end font-mono text-xs text-muted-foreground">{e.phone}</p>
|
||||
{e.baseSalary > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{formatCurrency(e.baseSalary)}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "attendance" && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
|
||||
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
@@ -19,6 +20,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
|
||||
type Ingredient = {
|
||||
id: string;
|
||||
@@ -67,6 +69,7 @@ type PurchasesSummary = {
|
||||
export function InventoryScreen() {
|
||||
const t = useTranslations("inventory");
|
||||
const tCommon = useTranslations("common");
|
||||
const apiError = useApiError();
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
@@ -95,6 +98,7 @@ export function InventoryScreen() {
|
||||
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
|
||||
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Ingredient | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editUnit, setEditUnit] = useState("گرم");
|
||||
const [editReorder, setEditReorder] = useState("0");
|
||||
@@ -198,6 +202,17 @@ export function InventoryScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteIngredient = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete(`/api/cafes/${cafeId}/inventory/ingredients/${id}`),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
|
||||
setDeleteTarget(null);
|
||||
notify.success(t("deleted"));
|
||||
},
|
||||
onError: (err) => notify.error(apiError(err)),
|
||||
});
|
||||
|
||||
const adjustStock = useMutation({
|
||||
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
|
||||
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
|
||||
@@ -478,6 +493,16 @@ export function InventoryScreen() {
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
aria-label={tCommon("delete")}
|
||||
onClick={() => setDeleteTarget(ing)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[#0F6E56]">
|
||||
@@ -661,6 +686,17 @@ export function InventoryScreen() {
|
||||
) : null}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteTarget(null);
|
||||
}}
|
||||
title={t("deleteConfirmTitle")}
|
||||
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
|
||||
busy={deleteIngredient.isPending}
|
||||
onConfirm={() => deleteTarget && deleteIngredient.mutate(deleteTarget.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,11 @@ export function KdsScreen() {
|
||||
<li key={item.id}>
|
||||
{formatNumber(item.quantity, numberLocale)}×{" "}
|
||||
{item.menuItemName}
|
||||
{item.notes ? (
|
||||
<span className="mt-0.5 block rounded bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
|
||||
✍️ {item.notes}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -193,6 +193,8 @@ export function BranchMenuOverrides({
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
// Force LTR so the knob's translate-x stays inside the track in RTL.
|
||||
dir="ltr"
|
||||
aria-checked={row.isAvailable}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react";
|
||||
import { Box, Pencil, Plus, Search, Trash2, Video, X } from "lucide-react";
|
||||
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
||||
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
||||
@@ -12,8 +12,19 @@ import { CategoryVisual } from "@/components/menu/category-visual";
|
||||
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
||||
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
||||
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
@@ -126,6 +137,9 @@ function ToggleSwitch({
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
type="button"
|
||||
// Force LTR so the knob's translate-x stays inside the track; in RTL the
|
||||
// flex start sits on the right and translate-x-4 would push it out.
|
||||
dir="ltr"
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200",
|
||||
@@ -184,11 +198,8 @@ function Modal({
|
||||
export function MenuAdminScreen() {
|
||||
const t = useTranslations("menuAdmin");
|
||||
const tCommon = useTranslations("common");
|
||||
const tNotify = useTranslations("notify");
|
||||
const showError = (err: unknown) =>
|
||||
notify.error(
|
||||
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
|
||||
);
|
||||
const apiError = useApiError();
|
||||
const showError = (err: unknown) => notify.error(apiError(err));
|
||||
const isRtl = useIsRtl();
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
@@ -211,6 +222,11 @@ export function MenuAdminScreen() {
|
||||
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
|
||||
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
|
||||
|
||||
// Delete confirmation (shared dialog for items + categories)
|
||||
const [confirmDelete, setConfirmDelete] = useState<
|
||||
{ kind: "item" | "category"; id: string; name: string } | null
|
||||
>(null);
|
||||
|
||||
// ── Data queries ───────────────────────────────────────────────────────────
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["menu-categories", cafeId],
|
||||
@@ -301,6 +317,30 @@ export function MenuAdminScreen() {
|
||||
onError: showError,
|
||||
});
|
||||
|
||||
const deleteItemMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/items/${id}`),
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(null);
|
||||
setItemModalOpen(false);
|
||||
notify.success(t("deleteItemSuccess"));
|
||||
invalidateMenu();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
|
||||
const deleteCategoryMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/categories/${id}`),
|
||||
onSuccess: (_data, id) => {
|
||||
setConfirmDelete(null);
|
||||
setCatModalOpen(false);
|
||||
// If the deleted category was selected, fall back to "all items".
|
||||
setSelectedCategoryId((prev) => (prev === id ? "all" : prev));
|
||||
notify.success(t("deleteCategorySuccess"));
|
||||
invalidateMenu();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
|
||||
const addCategoryMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
||||
@@ -893,11 +933,28 @@ export function MenuAdminScreen() {
|
||||
</LabeledField>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
|
||||
{editingItem ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setItemModalOpen(false)}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() =>
|
||||
setConfirmDelete({
|
||||
kind: "item",
|
||||
id: editingItem.id,
|
||||
name: editingItem.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="me-1.5 size-4" />
|
||||
{tCommon("delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setItemModalOpen(false)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -909,6 +966,7 @@ export function MenuAdminScreen() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ── Category Add / Edit Modal ─────────────────────────────────────── */}
|
||||
@@ -941,7 +999,27 @@ export function MenuAdminScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
|
||||
{editingCategory ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() =>
|
||||
setConfirmDelete({
|
||||
kind: "category",
|
||||
id: editingCategory.id,
|
||||
name: editingCategory.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="me-1.5 size-4" />
|
||||
{tCommon("delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
@@ -954,7 +1032,51 @@ export function MenuAdminScreen() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ── Delete confirmation (items + categories) ──────────────────────── */}
|
||||
<AlertDialog
|
||||
open={!!confirmDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDelete(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{confirmDelete?.kind === "category"
|
||||
? t("deleteCategoryConfirmTitle")
|
||||
: t("deleteItemConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{confirmDelete?.kind === "category"
|
||||
? t("deleteCategoryConfirmDesc", { name: confirmDelete?.name ?? "" })
|
||||
: t("deleteItemConfirmDesc", { name: confirmDelete?.name ?? "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
disabled={deleteItemMutation.isPending || deleteCategoryMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // keep dialog open until the mutation resolves
|
||||
if (!confirmDelete) return;
|
||||
if (confirmDelete.kind === "category") {
|
||||
deleteCategoryMutation.mutate(confirmDelete.id);
|
||||
} else {
|
||||
deleteItemMutation.mutate(confirmDelete.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleteItemMutation.isPending || deleteCategoryMutation.isPending
|
||||
? t("saving")
|
||||
: tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ export function PosScreen() {
|
||||
addItem,
|
||||
removeItem,
|
||||
updateQty,
|
||||
setNotes,
|
||||
couponCode,
|
||||
appliedCoupon,
|
||||
setCouponCode,
|
||||
@@ -1210,10 +1211,11 @@ export function PosScreen() {
|
||||
<div
|
||||
key={line.orderItemId ?? line.menuItem.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border border-border p-2",
|
||||
"flex flex-col gap-1.5 rounded-lg border border-border p-2",
|
||||
line.isVoided && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MenuItemLabels
|
||||
item={line.menuItem}
|
||||
@@ -1292,6 +1294,18 @@ export function PosScreen() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!line.isVoided && (
|
||||
<input
|
||||
type="text"
|
||||
value={line.notes ?? ""}
|
||||
onChange={(e) => setNotes(line.menuItem.id, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder={t("itemNotePlaceholder")}
|
||||
className="w-full rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { restoreQueryCache, startPersisting } from "@/lib/offline/query-persister";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, retry: 1 },
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
// Keep data in memory long enough to back offline reads; it is also
|
||||
// persisted to IndexedDB by the persister below.
|
||||
gcTime: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Persist the query cache to IndexedDB so the dashboard is viewable offline.
|
||||
// Scoped to the current café so a different tenant never hydrates this data.
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
useEffect(() => {
|
||||
const scope = cafeId ?? "anon";
|
||||
let active = true;
|
||||
let stop: () => void = () => {};
|
||||
void (async () => {
|
||||
await restoreQueryCache(queryClient, scope);
|
||||
if (!active) return;
|
||||
stop = startPersisting(queryClient, scope);
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
stop();
|
||||
};
|
||||
}, [queryClient, cafeId]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfirmProvider>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
||||
const [tableOrders, setTableOrders] = useState<GuestOrderRef[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [menuTheme, setMenuTheme] = useState<CafeTheme | null>(null);
|
||||
const [showWatermark, setShowWatermark] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [view3dItem, setView3dItem] = useState<QrPublicMenuItem | null>(null);
|
||||
const [security, setSecurity] = useState<PublicSecurityConfig | null>(null);
|
||||
@@ -111,6 +112,7 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
||||
const cats = menu.categories ?? [];
|
||||
setCategories(cats);
|
||||
setMenuTheme(normalizeCafeTheme(menu.theme ?? undefined));
|
||||
setShowWatermark(menu.showWatermark ?? false);
|
||||
setActiveCategory(QR_ALL_CATEGORY_ID);
|
||||
if (cats.length === 0) {
|
||||
setError(t("emptyMenu"));
|
||||
@@ -407,8 +409,9 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
||||
{cart.map((c) => (
|
||||
<div
|
||||
key={c.item.id}
|
||||
className="flex items-center justify-between gap-3 border-b px-3 py-3 last:border-0"
|
||||
className="flex flex-col gap-2 border-b px-3 py-3 last:border-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
|
||||
<p className="text-sm font-medium" style={{ color: primary }}>
|
||||
@@ -431,6 +434,20 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={c.note ?? ""}
|
||||
onChange={(e) =>
|
||||
setCart((prev) =>
|
||||
prev.map((l) =>
|
||||
l.item.id === c.item.id ? { ...l, note: e.target.value } : l
|
||||
)
|
||||
)
|
||||
}
|
||||
placeholder={t("itemNote")}
|
||||
className="w-full rounded-md border qr-border bg-transparent px-2 py-1.5 text-xs placeholder:opacity-60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
@@ -550,6 +567,16 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
||||
view3d: t("view3d"),
|
||||
}}
|
||||
/>
|
||||
{showWatermark ? (
|
||||
<a
|
||||
href="https://meezi.ir"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1 py-5 text-xs qr-muted opacity-70"
|
||||
>
|
||||
ساختهشده با <span className="font-bold">میزی</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{totalItems > 0 ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-[3.25rem] z-40 mx-auto max-w-md px-3 pb-1">
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -45,8 +49,11 @@ const statusStyle: Record<ReservationStatus, string> = {
|
||||
|
||||
export function ReservationsScreen() {
|
||||
const t = useTranslations("reservations");
|
||||
const tCommon = useTranslations("common");
|
||||
const apiError = useApiError();
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteTarget, setDeleteTarget] = useState<Reservation | null>(null);
|
||||
|
||||
const [guestName, setGuestName] = useState("");
|
||||
const [guestPhone, setGuestPhone] = useState("09121234567");
|
||||
@@ -92,6 +99,16 @@ export function ReservationsScreen() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }),
|
||||
});
|
||||
|
||||
const deleteReservation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/reservations/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
|
||||
setDeleteTarget(null);
|
||||
notify.success(t("deleted"));
|
||||
},
|
||||
onError: (err) => notify.error(apiError(err)),
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
const posHref = (r: Reservation) => {
|
||||
@@ -245,6 +262,15 @@ export function ReservationsScreen() {
|
||||
{t("markCompleted")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
aria-label={tCommon("delete")}
|
||||
onClick={() => setDeleteTarget(r)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -252,6 +278,19 @@ export function ReservationsScreen() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteTarget(null);
|
||||
}}
|
||||
title={t("deleteConfirmTitle")}
|
||||
description={
|
||||
deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.guestName }) : undefined
|
||||
}
|
||||
busy={deleteReservation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteReservation.mutate(deleteTarget.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,6 +366,26 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
|
||||
>
|
||||
ذخیره موقعیت
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
||||
notify.error("مرورگر شما موقعیتیابی را پشتیبانی نمیکند");
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLatInput(pos.coords.latitude.toFixed(5));
|
||||
setLngInput(pos.coords.longitude.toFixed(5));
|
||||
setLocationError(null);
|
||||
},
|
||||
() => notify.error("دسترسی به موقعیت امکانپذیر نبود. لطفاً اجازه دسترسی بدهید."),
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
}}
|
||||
>
|
||||
موقعیت فعلی من
|
||||
</Button>
|
||||
{(latInput || lngInput) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
@@ -34,6 +35,7 @@ export function CheckoutScreen() {
|
||||
const t = useTranslations("subscription");
|
||||
const tc = useTranslations("subscription.checkout");
|
||||
const tPlans = useTranslations("settings.plans");
|
||||
const apiError = useApiError();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -66,6 +68,37 @@ export function CheckoutScreen() {
|
||||
enabled: !!cafeId && isCafeOwner(role),
|
||||
});
|
||||
|
||||
// If the owner is still covered (active plan and/or queued plans), this purchase will be
|
||||
// queued to start when the current coverage ends rather than activating immediately.
|
||||
const { data: billingStatus } = useQuery({
|
||||
queryKey: ["billing-status", cafeId],
|
||||
queryFn: () =>
|
||||
apiGet<{
|
||||
planTier: string;
|
||||
planExpiresAt: string | null;
|
||||
isPlanExpired: boolean;
|
||||
queuedPlans: { effectiveTo: string }[];
|
||||
}>("/api/billing/status"),
|
||||
enabled: !!cafeId && isCafeOwner(role),
|
||||
});
|
||||
|
||||
const coverageEnd = useMemo(() => {
|
||||
if (!billingStatus) return null;
|
||||
const now = Date.now();
|
||||
let end = 0;
|
||||
if (
|
||||
billingStatus.planTier !== "Free" &&
|
||||
billingStatus.planExpiresAt &&
|
||||
!billingStatus.isPlanExpired
|
||||
) {
|
||||
end = Math.max(end, new Date(billingStatus.planExpiresAt).getTime());
|
||||
}
|
||||
for (const q of billingStatus.queuedPlans ?? []) {
|
||||
end = Math.max(end, new Date(q.effectiveTo).getTime());
|
||||
}
|
||||
return end > now ? new Date(end) : null;
|
||||
}, [billingStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentMethod && paymentMethods.length > 0) {
|
||||
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
||||
@@ -81,8 +114,7 @@ export function CheckoutScreen() {
|
||||
window.location.href = data.paymentUrl;
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setPayError(msg || tc("paymentFailed"));
|
||||
setPayError(apiError(err, tc("paymentFailed")));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -139,6 +171,13 @@ export function CheckoutScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
{coverageEnd ? (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/50 px-4 py-3 text-sm text-[#0F6E56]">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||
<p>{tc("queuedNotice", { date: coverageEnd.toLocaleDateString(numberLocale) })}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Factor / invoice */}
|
||||
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
|
||||
{/* Invoice header */}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CalendarClock, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
|
||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
@@ -14,9 +15,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { PlanComparison } from "@/components/settings/plan-comparison";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
|
||||
type QueuedPlan = {
|
||||
paymentId: string;
|
||||
planTier: string;
|
||||
months: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string;
|
||||
amountToman: number;
|
||||
};
|
||||
|
||||
type BillingStatus = {
|
||||
planTier: string;
|
||||
@@ -30,6 +42,7 @@ type BillingStatus = {
|
||||
menu3dEnabled: boolean;
|
||||
discoverProfileEnabled: boolean;
|
||||
isPlanExpired: boolean;
|
||||
queuedPlans: QueuedPlan[];
|
||||
};
|
||||
|
||||
export function SubscriptionScreen() {
|
||||
@@ -40,8 +53,11 @@ export function SubscriptionScreen() {
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const apiError = useApiError();
|
||||
const queryClient = useQueryClient();
|
||||
const billingRefreshed = useRef(false);
|
||||
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<QueuedPlan | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const billing = searchParams.get("billing");
|
||||
@@ -61,6 +77,18 @@ export function SubscriptionScreen() {
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const cancelQueued = useMutation({
|
||||
mutationFn: (paymentId: string) => apiDelete(`/api/billing/queued/${paymentId}`),
|
||||
onSuccess: () => {
|
||||
setCancelTarget(null);
|
||||
notify.success(t("queued.cancelled"));
|
||||
queryClient.invalidateQueries({ queryKey: ["billing-status", cafeId] });
|
||||
},
|
||||
onError: (err) => notify.error(apiError(err)),
|
||||
});
|
||||
|
||||
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString("fa-IR");
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
||||
const refresh = localStorage.getItem("meezi_refresh_token");
|
||||
@@ -155,12 +183,72 @@ export function SubscriptionScreen() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{status?.queuedPlans && status.queuedPlans.length > 0 ? (
|
||||
<Card className="rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/30 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarClock className="size-4 text-[#0F6E56]" aria-hidden />
|
||||
{t("queued.title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t("queued.subtitle")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{status.queuedPlans.map((q) => (
|
||||
<div
|
||||
key={q.paymentId}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{q.planTier}</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("queued.months", { count: formatNumber(q.months) })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("queued.window", { from: fmtDate(q.effectiveFrom), to: fmtDate(q.effectiveTo) })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() => setCancelTarget(q)}
|
||||
>
|
||||
<Trash2 className="me-1.5 size-4" />
|
||||
{t("queued.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<PlanComparison
|
||||
currentPlan={status?.planTier ?? "Free"}
|
||||
onSubscribe={(planTier) =>
|
||||
router.push(`/subscription/checkout?plan=${planTier}`)
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!cancelTarget}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setCancelTarget(null);
|
||||
}}
|
||||
title={t("queued.cancelConfirmTitle")}
|
||||
description={
|
||||
cancelTarget
|
||||
? t("queued.cancelConfirmDesc", {
|
||||
plan: cancelTarget.planTier,
|
||||
from: fmtDate(cancelTarget.effectiveFrom),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={t("queued.cancel")}
|
||||
busy={cancelQueued.isPending}
|
||||
onConfirm={() => cancelTarget && cancelQueued.mutate(cancelTarget.paymentId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import { useApiError } from "@/lib/use-api-error";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -52,6 +53,7 @@ function formatDate(iso: string) {
|
||||
|
||||
export function SupportScreen() {
|
||||
const t = useTranslations("support");
|
||||
const apiError = useApiError();
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
@@ -61,6 +63,7 @@ export function SupportScreen() {
|
||||
data: tickets = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["support", cafeId],
|
||||
@@ -135,7 +138,7 @@ export function SupportScreen() {
|
||||
</p>
|
||||
{isError ? (
|
||||
<Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive">
|
||||
<p>{t("loadFailed")}</p>
|
||||
<p>{apiError(error, t("loadFailed"))}</p>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user