From 4226f4cf0508d59ef2d680f40908509e89696f13 Mon Sep 17 00:00:00 2001 From: leo <25032781+LEO0331@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:34:04 +0800 Subject: [PATCH] more badge --- docs/ops/verified_enterprises.seed.json | 62 +++-- lib/app/app_bootstrap.dart | 13 + lib/core/i18n/app_strings.dart | 18 ++ lib/features/surplus/domain/listing.dart | 9 + .../browse/listing_detail_page.dart | 26 +- .../presentation/browse/listings_page.dart | 15 +- .../browse/my_reservations_page.dart | 57 +++-- .../browse/reservation_confirmation_page.dart | 93 +++----- scripts/seed_verified_enterprises.js | 35 ++- server/README.md | 37 ++- server/index.js | 222 ++++++++++++++++++ .../browse/my_reservations_page_test.dart | 4 +- 12 files changed, 471 insertions(+), 120 deletions(-) diff --git a/docs/ops/verified_enterprises.seed.json b/docs/ops/verified_enterprises.seed.json index a7803de..baddf1f 100644 --- a/docs/ops/verified_enterprises.seed.json +++ b/docs/ops/verified_enterprises.seed.json @@ -1,18 +1,46 @@ -[ - { - "aliasNormalized": "acme booth a", - "venueId": "taipei-nangang-exhibition-center-hall-1", - "active": true, - "reviewedBy": "ops-leo", - "reviewedAt": "2026-04-02T00:00:00.000Z", - "notes": "manual verification passed" - }, - { - "aliasNormalized": "demo drinks co", - "venueId": "taipei-nangang-exhibition-center-hall-2", - "active": true, - "reviewedBy": "ops-leo", - "reviewedAt": "2026-04-02T00:00:00.000Z", - "notes": "event organizer confirmation" +{ + "verifiedEnterprises": [ + { + "aliasNormalized": "acme booth a", + "venueId": "taipei-nangang-exhibition-center-hall-1", + "active": true, + "reviewedBy": "ops-leo", + "reviewedAt": "2026-04-02T00:00:00.000Z", + "notes": "manual verification passed" + }, + { + "aliasNormalized": "demo drinks co", + "venueId": "taipei-nangang-exhibition-center-hall-2", + "active": true, + "reviewedBy": "ops-leo", + "reviewedAt": "2026-04-02T00:00:00.000Z", + "notes": "event organizer confirmation" + } + ], + "badgeRules": { + "enabled": true, + "rules": { + "verified": { + "enabled": true + }, + "quality_trusted": { + "enabled": true, + "minCompletedRate": 0.8, + "maxCancelledRate": 0.2, + "minReservations": 5 + }, + "high_impact": { + "enabled": true, + "minQuantityTotal": 20 + }, + "flexible_pickup": { + "enabled": true, + "minPickupWindowMinutes": 120 + }, + "stable_shelf_life": { + "enabled": true, + "minExpiryAfterPickupStartMinutes": 120 + } + } } -] +} diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart index 62ee275..cfa5db5 100644 --- a/lib/app/app_bootstrap.dart +++ b/lib/app/app_bootstrap.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:developer' as developer; @@ -5,6 +6,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../core/identity/firebase_recipient_identity_service.dart'; @@ -45,6 +47,7 @@ Future bootstrapApp() async { }, ); await repository.ensureSeedData(); + unawaited(_warmUpApi(apiBaseUrl)); final identity = FirebaseRecipientIdentityService( FirebaseAuth.instance, @@ -83,3 +86,13 @@ Future bootstrapApp() async { ); } } + +Future _warmUpApi(String apiBaseUrl) async { + final normalized = apiBaseUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$normalized/health'); + try { + await http.get(uri).timeout(const Duration(seconds: 6)); + } catch (_) { + // Best-effort warm-up only. + } +} diff --git a/lib/core/i18n/app_strings.dart b/lib/core/i18n/app_strings.dart index 2332445..1a799eb 100644 --- a/lib/core/i18n/app_strings.dart +++ b/lib/core/i18n/app_strings.dart @@ -85,6 +85,7 @@ class AppStrings { String get riskReasonOther => _zh ? '其他風險' : 'Other risk'; String get abuseReported => _zh ? '已送出風險回報。' : 'Safety report submitted.'; String get verifiedEnterprise => _zh ? '已驗證企業' : 'Verified enterprise'; + String get trustedQualityEnterprise => _zh ? '交付品質穩定' : 'Trusted handoff quality'; String get highImpactEnterprise => _zh ? '高量捐贈企業' : 'High-impact donor'; String get flexiblePickupEnterprise => _zh ? '彈性取餐時段' : 'Flexible pickup window'; String get stableShelfLifeEnterprise => _zh ? '保存時效較穩定' : 'Stable shelf-life setup'; @@ -122,4 +123,21 @@ class AppStrings { return _zh ? '已取消' : 'Cancelled'; } } + + String? enterpriseBadgeLabel(String badgeId) { + switch (badgeId) { + case 'verified': + return verifiedEnterprise; + case 'quality_trusted': + return trustedQualityEnterprise; + case 'high_impact': + return highImpactEnterprise; + case 'flexible_pickup': + return flexiblePickupEnterprise; + case 'stable_shelf_life': + return stableShelfLifeEnterprise; + default: + return null; + } + } } diff --git a/lib/features/surplus/domain/listing.dart b/lib/features/surplus/domain/listing.dart index 283b78e..30a64ad 100644 --- a/lib/features/surplus/domain/listing.dart +++ b/lib/features/surplus/domain/listing.dart @@ -51,6 +51,7 @@ class Listing { required this.displayNameOptional, this.templateId, this.enterpriseVerified = false, + this.enterpriseBadges = const [], required this.visibility, required this.status, required this.editTokenHash, @@ -73,6 +74,7 @@ class Listing { final String? displayNameOptional; final String? templateId; final bool enterpriseVerified; + final List enterpriseBadges; final ListingVisibility visibility; final ListingStatus status; final String editTokenHash; @@ -116,6 +118,7 @@ class Listing { String? displayNameOptional, String? templateId, bool? enterpriseVerified, + List? enterpriseBadges, ListingVisibility? visibility, ListingStatus? status, String? editTokenHash, @@ -138,6 +141,7 @@ class Listing { displayNameOptional: displayNameOptional ?? this.displayNameOptional, templateId: templateId ?? this.templateId, enterpriseVerified: enterpriseVerified ?? this.enterpriseVerified, + enterpriseBadges: enterpriseBadges ?? this.enterpriseBadges, visibility: visibility ?? this.visibility, status: status ?? this.status, editTokenHash: editTokenHash ?? this.editTokenHash, @@ -162,6 +166,7 @@ class Listing { 'displayNameOptional': displayNameOptional, 'templateId': templateId, 'enterpriseVerified': enterpriseVerified, + 'enterpriseBadges': enterpriseBadges, 'visibility': visibility.name, 'status': status.name, 'editTokenHash': editTokenHash, @@ -187,6 +192,10 @@ class Listing { displayNameOptional: map['displayNameOptional'] as String?, templateId: map['templateId'] as String?, enterpriseVerified: map['enterpriseVerified'] == true, + enterpriseBadges: (map['enterpriseBadges'] as List?) + ?.whereType() + .toList() ?? + const [], visibility: ListingVisibilityX.fromName(map['visibility'] as String?), status: ListingStatusX.fromName(map['status'] as String?), editTokenHash: map['editTokenHash'] as String? ?? '', diff --git a/lib/features/surplus/presentation/browse/listing_detail_page.dart b/lib/features/surplus/presentation/browse/listing_detail_page.dart index fed1710..913c478 100644 --- a/lib/features/surplus/presentation/browse/listing_detail_page.dart +++ b/lib/features/surplus/presentation/browse/listing_detail_page.dart @@ -213,15 +213,25 @@ class _ListingDetailPageState extends State { Text( 'Donor: ${listing.displayNameOptional?.trim().isNotEmpty == true ? listing.displayNameOptional : s.privateDonor}', ), - if (listing.enterpriseVerified) ...[ + if (listing.enterpriseBadges.isNotEmpty) ...[ const SizedBox(height: 6), - Chip( - avatar: const Icon( - Icons.verified, - size: 16, - color: Color(0xFF2D6A4F), - ), - label: Text(s.verifiedEnterprise), + Wrap( + spacing: 6, + runSpacing: 6, + children: listing.enterpriseBadges + .map(s.enterpriseBadgeLabel) + .whereType() + .map( + (label) => Chip( + avatar: const Icon( + Icons.verified, + size: 16, + color: Color(0xFF2D6A4F), + ), + label: Text(label), + ), + ) + .toList(), ), ], const SizedBox(height: 8), diff --git a/lib/features/surplus/presentation/browse/listings_page.dart b/lib/features/surplus/presentation/browse/listings_page.dart index 13644bd..652b62b 100644 --- a/lib/features/surplus/presentation/browse/listings_page.dart +++ b/lib/features/surplus/presentation/browse/listings_page.dart @@ -316,9 +316,16 @@ class _ListingsPageState extends State { final isFavorite = favoritesStore.isFavorite( listing.venueId, ); - final verifiedTag = listing.enterpriseVerified - ? ' · ${s.verifiedEnterprise}' - : ''; + final badgeLabels = listing.enterpriseBadges + .map(s.enterpriseBadgeLabel) + .whereType() + .toList(); + final badgeTag = badgeLabels.isEmpty + ? null + : badgeLabels.first; + final donorSuffix = badgeTag == null + ? '' + : ' · $badgeTag'; return Card( child: ListTile( @@ -328,7 +335,7 @@ class _ListingsPageState extends State { subtitle: Text( '${venue?.name ?? 'Venue'}\n' 'Pickup: ${formatDateTime(listing.pickupStartAt)} - ${formatDateTime(listing.pickupEndAt)}\n' - 'By: $donorName$verifiedTag', + 'By: $donorName$donorSuffix', ), isThreeLine: true, trailing: SizedBox( diff --git a/lib/features/surplus/presentation/browse/my_reservations_page.dart b/lib/features/surplus/presentation/browse/my_reservations_page.dart index affe78e..ddfbc2a 100644 --- a/lib/features/surplus/presentation/browse/my_reservations_page.dart +++ b/lib/features/surplus/presentation/browse/my_reservations_page.dart @@ -28,6 +28,7 @@ class MyReservationsPage extends StatefulWidget { } class _MyReservationsPageState extends State { + static const Duration _loadRetryDelay = Duration(milliseconds: 700); Future? _uidFuture; Future>? _loadFuture; @@ -39,6 +40,18 @@ class _MyReservationsPageState extends State { } Future> _loadReservations() async { + try { + return await _loadReservationsOnce(); + } on SurplusException { + await Future.delayed(_loadRetryDelay); + return _loadReservationsOnce(); + } catch (_) { + await Future.delayed(_loadRetryDelay); + return _loadReservationsOnce(); + } + } + + Future> _loadReservationsOnce() async { final deps = AppScope.of(context); final uid = await (_uidFuture ?? deps.identityService.ensureRecipientUid()); final reservations = await deps.repository.listRecipientReservations( @@ -165,16 +178,6 @@ class _MyReservationsPageState extends State { ); } - final aliasFrequency = {}; - for (final item in items) { - final alias = item.listing?.displayNameOptional?.trim() ?? ''; - if (alias.isEmpty) { - continue; - } - final normalized = alias.toLowerCase(); - aliasFrequency[normalized] = (aliasFrequency[normalized] ?? 0) + 1; - } - return ListView( padding: const EdgeInsets.all(12), children: [ @@ -207,10 +210,10 @@ class _MyReservationsPageState extends State { ...items.map((item) { final reservation = item.reservation; final listing = item.listing; - final alias = listing?.displayNameOptional?.trim() ?? ''; - final isFrequentEnterprise = - alias.isNotEmpty && - (aliasFrequency[alias.toLowerCase()] ?? 0) >= 2; + final badgeLabels = (listing?.enterpriseBadges ?? const []) + .map(s.enterpriseBadgeLabel) + .whereType() + .toList(); final statusLabel = s.statusLabel(switch (reservation.status) { ReservationStatus.reserved => AppStatusLabel.reserved, ReservationStatus.completed => AppStatusLabel.completed, @@ -255,16 +258,24 @@ class _MyReservationsPageState extends State { 'Pickup: ${listing?.pickupPointText ?? '-'}\n' 'Expires: ${formatDateTime(reservation.expiresAt)}', ), - if (isFrequentEnterprise) ...[ + if (badgeLabels.isNotEmpty) ...[ const SizedBox(height: 6), - Chip( - visualDensity: VisualDensity.compact, - avatar: const Icon( - Icons.verified, - size: 16, - color: Color(0xFF2D6A4F), - ), - label: Text(s.frequentEnterprise), + Wrap( + spacing: 6, + runSpacing: 6, + children: badgeLabels + .map( + (label) => Chip( + visualDensity: VisualDensity.compact, + avatar: const Icon( + Icons.verified, + size: 16, + color: Color(0xFF2D6A4F), + ), + label: Text(label), + ), + ) + .toList(), ), ], ], diff --git a/lib/features/surplus/presentation/browse/reservation_confirmation_page.dart b/lib/features/surplus/presentation/browse/reservation_confirmation_page.dart index e351c4b..e4e5428 100644 --- a/lib/features/surplus/presentation/browse/reservation_confirmation_page.dart +++ b/lib/features/surplus/presentation/browse/reservation_confirmation_page.dart @@ -106,30 +106,21 @@ class ReservationConfirmationPage extends StatelessWidget { } } - List<({IconData icon, String label})> _enterpriseBadges({ - required Listing listing, - required AppStrings s, - required bool isFrequentEnterprise, - }) { - final badges = <({IconData icon, String label})>[]; - if (listing.enterpriseVerified) { - badges.add((icon: Icons.verified, label: s.verifiedEnterprise)); + IconData _badgeIcon(String badgeId) { + switch (badgeId) { + case 'verified': + return Icons.verified; + case 'quality_trusted': + return Icons.emoji_events_outlined; + case 'high_impact': + return Icons.workspace_premium_outlined; + case 'flexible_pickup': + return Icons.schedule_outlined; + case 'stable_shelf_life': + return Icons.inventory_2_outlined; + default: + return Icons.star_outline; } - if (isFrequentEnterprise) { - badges.add((icon: Icons.eco_outlined, label: s.frequentEnterprise)); - } - if (listing.quantityTotal >= 20) { - badges.add((icon: Icons.workspace_premium_outlined, label: s.highImpactEnterprise)); - } - if (listing.pickupEndAt.difference(listing.pickupStartAt) >= - const Duration(minutes: 120)) { - badges.add((icon: Icons.schedule_outlined, label: s.flexiblePickupEnterprise)); - } - if (listing.expiresAt.difference(listing.pickupStartAt) >= - const Duration(minutes: 120)) { - badges.add((icon: Icons.inventory_2_outlined, label: s.stableShelfLifeEnterprise)); - } - return badges; } @override @@ -178,26 +169,11 @@ class ReservationConfirmationPage extends StatelessWidget { } final listing = listingSnapshot.data; - return StreamBuilder>( - stream: repository.watchActiveListings(), - builder: (context, activeListingsSnapshot) { - final activeListings = - activeListingsSnapshot.data ?? const []; - final alias = listing?.displayNameOptional?.trim() ?? ''; - final isFrequentEnterprise = - alias.isNotEmpty && - activeListings - .where( - (item) => - (item.displayNameOptional ?? '') - .trim() - .toLowerCase() == - alias.toLowerCase(), - ) - .length >= - 2; + final badgeIds = (listing?.enterpriseBadges ?? const []) + .toSet() + .toList(); - return ListView( + return ListView( padding: const EdgeInsets.all(16), children: [ if (identityService.isUsingLocalFallback) @@ -307,11 +283,7 @@ class ReservationConfirmationPage extends StatelessWidget { Text( 'Enterprise: ${listing.displayNameOptional}', ), - if (_enterpriseBadges( - listing: listing, - s: s, - isFrequentEnterprise: isFrequentEnterprise, - ).isNotEmpty) + if (badgeIds.isNotEmpty) Padding( padding: const EdgeInsets.only( top: 6, @@ -320,22 +292,23 @@ class ReservationConfirmationPage extends StatelessWidget { child: Wrap( spacing: 8, runSpacing: 6, - children: _enterpriseBadges( - listing: listing, - s: s, - isFrequentEnterprise: - isFrequentEnterprise, - ) - .map( - (badge) => Chip( + children: badgeIds + .map((badgeId) { + final label = + s.enterpriseBadgeLabel(badgeId); + if (label == null) { + return null; + } + return Chip( avatar: Icon( - badge.icon, + _badgeIcon(badgeId), size: 16, color: const Color(0xFF2D6A4F), ), - label: Text(badge.label), - ), - ) + label: Text(label), + ); + }) + .whereType() .toList(), ), ), @@ -368,8 +341,6 @@ class ReservationConfirmationPage extends StatelessWidget { ), ], ); - }, - ); }, ); }, diff --git a/scripts/seed_verified_enterprises.js b/scripts/seed_verified_enterprises.js index 2500b0b..4c1b49d 100644 --- a/scripts/seed_verified_enterprises.js +++ b/scripts/seed_verified_enterprises.js @@ -45,20 +45,28 @@ async function main() { } const raw = fs.readFileSync(inputPath, 'utf8'); - let records; + let parsed; try { - records = JSON.parse(raw); + parsed = JSON.parse(raw); } catch (error) { fail(`Invalid JSON in ${inputPath}: ${error.message}`); } - + const records = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.verifiedEnterprises) + ? parsed.verifiedEnterprises + : null; + const badgeRules = Array.isArray(parsed) ? null : parsed?.badgeRules; if (!Array.isArray(records) || records.length === 0) { - fail('Seed data must be a non-empty JSON array.'); + fail( + 'Seed data must be a non-empty JSON array, or an object with verifiedEnterprises[].' + ); } initAdminFromEnv(); const db = admin.firestore(); const collection = db.collection('verified_enterprises'); + const badgeRulesRef = db.collection('badge_rules').doc('default'); const now = new Date(); let upserted = 0; @@ -98,11 +106,30 @@ async function main() { upserted += 1; } + let badgeRulesUpdated = false; + if (badgeRules && typeof badgeRules === 'object') { + const mergedBadgeRules = { + enabled: badgeRules.enabled !== false, + rules: badgeRules.rules && typeof badgeRules.rules === 'object' + ? badgeRules.rules + : {} + }; + await badgeRulesRef.set( + { + ...mergedBadgeRules, + updatedAt: now + }, + { merge: true } + ); + badgeRulesUpdated = true; + } + console.log( JSON.stringify({ ok: true, collection: 'verified_enterprises', upserted, + badgeRulesUpdated, source: inputPath }) ); diff --git a/server/README.md b/server/README.md index f219a86..676fc27 100644 --- a/server/README.md +++ b/server/README.md @@ -188,9 +188,44 @@ Rules: - `venueId` must match posting venue. - `active=true` means verified badge is granted. +## Badge Rules (Admin-configurable) + +`enterpriseBadges` are now computed by server from Firestore config doc: + +- collection: `badge_rules` +- document: `default` + +Example: + +```json +{ + "enabled": true, + "rules": { + "verified": { "enabled": true }, + "quality_trusted": { + "enabled": true, + "minCompletedRate": 0.8, + "maxCancelledRate": 0.2, + "minReservations": 5 + }, + "high_impact": { "enabled": true, "minQuantityTotal": 20 }, + "flexible_pickup": { "enabled": true, "minPickupWindowMinutes": 120 }, + "stable_shelf_life": { + "enabled": true, + "minExpiryAfterPickupStartMinutes": 120 + } + } +} +``` + +Admin location today: + +- Firebase Console -> Firestore -> `badge_rules/default` +- this project currently has no separate in-app `/admin` screen + ### Seed template + one-click import -You do **not** need to manually create the `verified_enterprises` collection first. +You do **not** need to manually create `verified_enterprises` or `badge_rules` first. Running the script will auto-create collection/documents. From repo root: diff --git a/server/index.js b/server/index.js index 2081fc3..0943828 100644 --- a/server/index.js +++ b/server/index.js @@ -36,6 +36,7 @@ const KPI_EVENTS = 'kpi_events'; const KPI_DAILY = 'kpi_daily'; const KPI_SUMMARY = 'kpi_summary'; const VERIFIED_ENTERPRISES = 'verified_enterprises'; +const BADGE_RULES = 'badge_rules'; const UNVERIFIED_DAILY_LIMIT = Math.max( 1, Number(process.env.UNVERIFIED_DAILY_LIMIT || 5) @@ -49,6 +50,25 @@ const LEGACY_RECIPIENT_DAILY_RESERVATION_LIMIT = Math.max( Number(process.env.LEGACY_RECIPIENT_DAILY_RESERVATION_LIMIT || 2) ); const REQUIRE_ID_TOKEN = String(process.env.REQUIRE_ID_TOKEN || 'false').toLowerCase() === 'true'; +const DEFAULT_BADGE_RULES = { + enabled: true, + rules: { + verified: { enabled: true }, + quality_trusted: { + enabled: true, + minCompletedRate: 0.8, + maxCancelledRate: 0.2, + minReservations: 5 + }, + high_impact: { enabled: true, minQuantityTotal: 20 }, + flexible_pickup: { enabled: true, minPickupWindowMinutes: 120 }, + stable_shelf_life: { enabled: true, minExpiryAfterPickupStartMinutes: 120 } + } +}; + +let cachedBadgeRules = null; +let cachedBadgeRulesAt = 0; +const BADGE_RULES_CACHE_TTL_MS = 60 * 1000; function sha256(value) { return crypto.createHash('sha256').update(value, 'utf8').digest('hex'); @@ -338,6 +358,44 @@ function resolveClientIp(req) { return String(req.ip || req.socket?.remoteAddress || 'unknown').trim(); } +function asNumber(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function isRuleEnabled(rule) { + return rule && rule.enabled !== false; +} + +async function getBadgeRules() { + const now = Date.now(); + if ( + cachedBadgeRules && + now - cachedBadgeRulesAt < BADGE_RULES_CACHE_TTL_MS + ) { + return cachedBadgeRules; + } + + try { + const snap = await db.collection(BADGE_RULES).doc('default').get(); + const raw = snap.exists ? snap.data() || {} : {}; + const merged = { + enabled: raw.enabled !== false, + rules: { + ...DEFAULT_BADGE_RULES.rules, + ...(raw.rules || {}) + } + }; + cachedBadgeRules = merged; + cachedBadgeRulesAt = now; + return merged; + } catch (_) { + cachedBadgeRules = DEFAULT_BADGE_RULES; + cachedBadgeRulesAt = now; + return DEFAULT_BADGE_RULES; + } +} + function deriveEnterpriseKey(req, payload) { const alias = normalizeAlias(payload.displayNameOptional); if (alias) { @@ -361,6 +419,143 @@ async function isEnterpriseVerified(payload) { return snap.docs.length > 0; } +async function computeEnterpriseReservationMetrics(enterpriseKey) { + if (!enterpriseKey) { + return { + totalReservations: 0, + completedReservations: 0, + cancelledReservations: 0, + completedRate: 0, + cancelledRate: 0 + }; + } + + const listingSnap = await db + .collection(LISTINGS) + .where('enterpriseKey', '==', enterpriseKey) + .limit(200) + .get(); + const listingIds = listingSnap.docs.map((doc) => doc.id); + if (listingIds.length === 0) { + return { + totalReservations: 0, + completedReservations: 0, + cancelledReservations: 0, + completedRate: 0, + cancelledRate: 0 + }; + } + + const chunks = []; + for (let i = 0; i < listingIds.length; i += 30) { + chunks.push(listingIds.slice(i, i + 30)); + } + + let totalReservations = 0; + let completedReservations = 0; + let cancelledReservations = 0; + for (const chunk of chunks) { + const reservationSnap = await db + .collection(RESERVATIONS) + .where('listingId', 'in', chunk) + .get(); + for (const doc of reservationSnap.docs) { + const status = String(doc.data()?.status || 'reserved'); + totalReservations += 1; + if (status === 'completed') { + completedReservations += 1; + } + if (status === 'cancelled') { + cancelledReservations += 1; + } + } + } + + const completedRate = + totalReservations > 0 ? completedReservations / totalReservations : 0; + const cancelledRate = + totalReservations > 0 ? cancelledReservations / totalReservations : 0; + + return { + totalReservations, + completedReservations, + cancelledReservations, + completedRate, + cancelledRate + }; +} + +async function resolveEnterpriseBadges({ listingData, enterpriseVerified }) { + const rulesRoot = await getBadgeRules(); + if (rulesRoot.enabled === false) { + return []; + } + const rules = rulesRoot.rules || {}; + const badges = []; + + if (enterpriseVerified && isRuleEnabled(rules.verified)) { + badges.push('verified'); + } + + if (isRuleEnabled(rules.high_impact)) { + const minQuantity = asNumber(rules.high_impact.minQuantityTotal, 20); + if (asNumber(listingData.quantityTotal, 0) >= minQuantity) { + badges.push('high_impact'); + } + } + + if (isRuleEnabled(rules.flexible_pickup)) { + const minMinutes = asNumber( + rules.flexible_pickup.minPickupWindowMinutes, + 120 + ); + const windowMs = + toMillis(listingData.pickupEndAt) - toMillis(listingData.pickupStartAt); + if (windowMs >= minMinutes * 60 * 1000) { + badges.push('flexible_pickup'); + } + } + + if (isRuleEnabled(rules.stable_shelf_life)) { + const minMinutes = asNumber( + rules.stable_shelf_life.minExpiryAfterPickupStartMinutes, + 120 + ); + const shelfMs = + toMillis(listingData.expiresAt) - toMillis(listingData.pickupStartAt); + if (shelfMs >= minMinutes * 60 * 1000) { + badges.push('stable_shelf_life'); + } + } + + if (isRuleEnabled(rules.quality_trusted)) { + const minCompletedRate = asNumber( + rules.quality_trusted.minCompletedRate, + 0.8 + ); + const maxCancelledRate = asNumber( + rules.quality_trusted.maxCancelledRate, + 0.2 + ); + const minReservations = asNumber( + rules.quality_trusted.minReservations, + 5 + ); + const metrics = await computeEnterpriseReservationMetrics( + String(listingData.enterpriseKey || '') + ); + if ( + metrics.totalReservations >= minReservations && + metrics.completedRate >= minCompletedRate && + metrics.cancelledRate <= maxCancelledRate + ) { + badges.push('quality_trusted'); + } + } + + return badges; +} + function resolveListingStatus({ currentStatus, expiresAt, quantityRemaining }) { if (currentStatus === 'completed') { return 'completed'; @@ -988,6 +1183,14 @@ app.post('/enterprise/listings/create', async (req, res) => { const now = nowDate(); const enterpriseKey = deriveEnterpriseKey(req, payload); const enterpriseVerified = await isEnterpriseVerified(payload); + const listingDraft = { + ...payload, + enterpriseKey + }; + const enterpriseBadges = await resolveEnterpriseBadges({ + listingData: listingDraft, + enterpriseVerified + }); if (!enterpriseVerified) { const from = startOfDay(now); @@ -1027,6 +1230,7 @@ app.post('/enterprise/listings/create', async (req, res) => { visibility: payload.visibility, status: 'active', enterpriseVerified, + enterpriseBadges, enterpriseKey, editTokenHash: sha256(token), createdAt: now, @@ -1170,12 +1374,30 @@ app.post('/enterprise/listings/:listingId/update', async (req, res) => { } const existing = verified.data; + const merged = { + ...existing, + ...payload + }; + const enterpriseKey = + String(existing.enterpriseKey || '').trim() || + deriveEnterpriseKey(req, merged); + const enterpriseVerified = await isEnterpriseVerified(merged); + const enterpriseBadges = await resolveEnterpriseBadges({ + listingData: { + ...merged, + enterpriseKey + }, + enterpriseVerified + }); const quantityTotal = payload.quantityTotal ?? existing.quantityTotal; const prevRemaining = Number(existing.quantityRemaining ?? 0); const nextRemaining = Math.max(0, Math.min(prevRemaining, quantityTotal)); const updateDoc = { ...payload, + enterpriseVerified, + enterpriseBadges, + enterpriseKey, quantityRemaining: nextRemaining, status: nextRemaining === 0 ? 'reserved' : 'active', updatedAt: nowDate() diff --git a/test/presentation/browse/my_reservations_page_test.dart b/test/presentation/browse/my_reservations_page_test.dart index 55de513..5db4d59 100644 --- a/test/presentation/browse/my_reservations_page_test.dart +++ b/test/presentation/browse/my_reservations_page_test.dart @@ -45,7 +45,7 @@ Future _pumpPage( } void main() { - testWidgets('shows privacy/faq and frequent enterprise badge', ( + testWidgets('shows privacy/faq without client-side inferred badge', ( tester, ) async { final repo = InMemorySurplusRepository(); @@ -75,7 +75,7 @@ void main() { expect(find.text('Privacy & FAQ'), findsOneWidget); expect(find.textContaining('Privacy note'), findsOneWidget); - expect(find.text('Frequent enterprise'), findsNWidgets(2)); + expect(find.text('Frequent enterprise'), findsNothing); }); testWidgets('cancel reservation action works', (tester) async {