From 9bf4e8894f6ed57edc7ec507cfa0fc37f905756a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 6 Jan 2026 20:57:10 +0100 Subject: [PATCH] feat: make mobile package shop adaptive and inventory-aware This commit includes: - Updating navigation to pass ?feature=advanced_analytics context. - Merging catalog with user inventory in MobilePackageShopPage. - Implementing smart sorting (recommended first, then price). - Adding highlighting and badges for recommended and active packages. - Displaying remaining event counts on package cards. --- .../js/admin/mobile/EventAnalyticsPage.tsx | 2 +- resources/js/admin/mobile/PackageShopPage.tsx | 146 ++++++++++++++---- 2 files changed, 120 insertions(+), 28 deletions(-) diff --git a/resources/js/admin/mobile/EventAnalyticsPage.tsx b/resources/js/admin/mobile/EventAnalyticsPage.tsx index 4f52067..7b2cb6e 100644 --- a/resources/js/admin/mobile/EventAnalyticsPage.tsx +++ b/resources/js/admin/mobile/EventAnalyticsPage.tsx @@ -66,7 +66,7 @@ export default function MobileEventAnalyticsPage() { navigate(adminPath('/mobile/billing/shop'))} + onPress={() => navigate(adminPath('/mobile/billing/shop?feature=advanced_analytics'))} /> diff --git a/resources/js/admin/mobile/PackageShopPage.tsx b/resources/js/admin/mobile/PackageShopPage.tsx index 9b5b340..95075b0 100644 --- a/resources/js/admin/mobile/PackageShopPage.tsx +++ b/resources/js/admin/mobile/PackageShopPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Check, ChevronRight, ShieldCheck, ShoppingBag } from 'lucide-react'; +import { Check, ChevronRight, ShieldCheck, ShoppingBag, Sparkles, Star } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Checkbox } from '@tamagui/checkbox'; @@ -10,21 +10,33 @@ import toast from 'react-hot-toast'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { useAdminTheme } from './theme'; -import { getPackages, createTenantPaddleCheckout, Package } from '../api'; +import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { useQuery } from '@tanstack/react-query'; export default function MobilePackageShopPage() { const { t } = useTranslation('management'); const navigate = useNavigate(); - const { textStrong, muted, border, primary, surface, accentSoft } = useAdminTheme(); + const location = useLocation(); + const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme(); const [selectedPackage, setSelectedPackage] = React.useState(null); - const { data: packages, isLoading } = useQuery({ + // Extract recommended feature from URL + const searchParams = new URLSearchParams(location.search); + const recommendedFeature = searchParams.get('feature'); + + const { data: catalog, isLoading: loadingCatalog } = useQuery({ queryKey: ['packages', 'endcustomer'], queryFn: () => getPackages('endcustomer'), }); + const { data: inventory, isLoading: loadingInventory } = useQuery({ + queryKey: ['tenant-packages-overview'], + queryFn: () => getTenantPackagesOverview({ force: true }), + }); + + const isLoading = loadingCatalog || loadingInventory; + if (isLoading) { return ( @@ -45,9 +57,36 @@ export default function MobilePackageShopPage() { ); } + // Merge and sort packages + const sortedPackages = [...(catalog || [])].sort((a, b) => { + // 1. Recommended feature first + const aHasFeature = recommendedFeature && a.features?.[recommendedFeature]; + const bHasFeature = recommendedFeature && b.features?.[recommendedFeature]; + if (aHasFeature && !bHasFeature) return -1; + if (!aHasFeature && bHasFeature) return 1; + + // 2. Inventory status (Owned packages later if they are fully used, but usually we want to show active stuff) + // Actually, let's keep price sorting as secondary + return a.price - b.price; + }); + return ( + {recommendedFeature && ( + + + + + {t('shop.recommendationTitle', 'Recommended for you')} + + + + {t('shop.recommendationBody', 'The highlighted package includes the feature you requested.')} + + + )} + {t('shop.subtitle', 'Choose a package to unlock more features and limits.')} @@ -55,44 +94,84 @@ export default function MobilePackageShopPage() { - {packages?.map((pkg) => ( - setSelectedPackage(pkg)} - /> - ))} + {sortedPackages.map((pkg) => { + const owned = inventory?.packages?.find(p => p.package_id === pkg.id); + const isActive = inventory?.activePackage?.package_id === pkg.id; + const isRecommended = recommendedFeature && pkg.features?.[recommendedFeature]; + + return ( + setSelectedPackage(pkg)} + /> + ); + })} ); } -function PackageShopCard({ pkg, onSelect }: { pkg: Package; onSelect: () => void }) { +function PackageShopCard({ + pkg, + owned, + isActive, + isRecommended, + onSelect +}: { + pkg: Package; + owned?: TenantPackageSummary; + isActive?: boolean; + isRecommended?: any; + onSelect: () => void +}) { const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { t } = useTranslation('management'); - // Parse features if they are stored as JSON strings or objects - // The API type says Record, but let's be safe - const features = pkg.features; + const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0); + const statusLabel = isActive + ? t('shop.status.active', 'Active Plan') + : owned + ? (owned.remaining_events !== null + ? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events }) + : t('shop.status.owned', 'Purchased')) + : null; return ( - - - - {pkg.name} - - - {new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)} - + + + + + {pkg.name} + + {isRecommended && {t('shop.badges.recommended', 'Recommended')}} + {isActive && {t('shop.badges.active', 'Active')}} + + + + + {new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)} + + {statusLabel && ( + + • {statusLabel} + + )} + - + @@ -104,9 +183,22 @@ function PackageShopCard({ pkg, onSelect }: { pkg: Package; onSelect: () => void {pkg.gallery_days ? ( ) : null} + + {/* Render specific feature if it was requested */} + {Object.entries(pkg.features || {}) + .filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos')) + .slice(0, 3) + .map(([key]) => ( + + )) + } - + ); }