Compare commits

...

3 Commits

Author SHA1 Message Date
Codex Agent
6542ac66f1 chore: update beads for adaptive shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 20:57:48 +01:00
Codex Agent
9bf4e8894f 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.
2026-01-06 20:57:10 +01:00
Codex Agent
704683421f chore: extract more translations for adaptive shop 2026-01-06 20:56:29 +01:00
6 changed files with 184 additions and 73 deletions

View File

@@ -46,6 +46,7 @@
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-9al","title":"Security review checklist: Marketing/API dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:35.116728385+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:35.116728385+01:00"}
{"id":"fotospiel-app-9em","title":"Implement Adaptive Mobile Package Shop","description":"Refine the MobilePackageShopPage to be context-aware and personalized.\n\n**Goals:**\n1. **Smart Sorting:** Highlight packages based on entry context (e.g., 'Upgrade for Analytics') and user inventory.\n2. **Inventory Awareness:** Display current ownership status (e.g., 'Active', '2 Events left') directly on cards.\n3. **Navigation Context:** Pass query params like '?feature=analytics' to trigger specific recommendations.\n\n**Tasks:**\n- Update navigation in to pass .\n- Fetch in to merge catalog with inventory.\n- Implement sorting logic: Recommendation \u003e Active \u003e Upgrades.\n- Add visual badges for 'Recommended', 'Active', and event counts.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T20:53:07.353435511+01:00","created_by":"soeren","updated_at":"2026-01-06T20:57:37.719610971+01:00","closed_at":"2026-01-06T20:57:37.719615677+01:00"}
{"id":"fotospiel-app-9gc","title":"Paddle migration: review current billing implementation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:04.715058376+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:10.363528452+01:00","closed_at":"2026-01-01T15:57:10.363528452+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-9ls","title":"SEC-API-02 Public API incident response playbook","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:35.519759351+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:41.160768858+01:00","closed_at":"2026-01-01T15:52:41.160768858+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-9mc","title":"SEC-FE-02 Consent-gated analytics loader","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:14.916352908+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:20.566910025+01:00","closed_at":"2026-01-01T15:55:20.566910025+01:00","close_reason":"Completed in codebase (verified)"}

View File

@@ -1 +1 @@
fotospiel-app-4en
fotospiel-app-9em

View File

@@ -944,9 +944,9 @@
"highlights.team.title": "Flexible team onboarding",
"home": {
"features_highlight": [
{},
{},
{}
null,
null,
null
],
"hero_bullets": [
null,
@@ -1040,9 +1040,9 @@
null
],
"steps": [
{},
{},
{}
null,
null,
null
]
},
"host": {
@@ -1052,34 +1052,34 @@
null
],
"steps": [
{},
{},
{}
null,
null,
null
]
}
},
"faq": {
"items": [
{},
{},
{},
{},
{},
{}
null,
null,
null,
null,
null,
null
]
},
"hero": {
"stats": [
{},
{},
{}
null,
null,
null
]
},
"pillars": [
{},
{},
{},
{}
null,
null,
null,
null
],
"timeline": [
{
@@ -2024,6 +2024,8 @@
"share_native": "__STRING_NOT_TRANSLATED__",
"share_title": "__STRING_NOT_TRANSLATED__",
"share_whatsapp": "__STRING_NOT_TRANSLATED__",
"shop.badges.active": "Active",
"shop.badges.recommended": "Recommended",
"shop.confirmSubtitle": "You are upgrading to:",
"shop.confirmTitle": "Confirm Purchase",
"shop.errors.checkout": "Checkout failed",
@@ -2035,9 +2037,16 @@
"shop.limits.photos_one": "{{count}} Photos",
"shop.limits.photos_other": "{{count}} Photos",
"shop.limits.unlimitedPhotos": "Unlimited Photos",
"shop.manage": "Manage Plan",
"shop.payNow": "Pay Now",
"shop.processing": "Processing...",
"shop.recommendationBody": "The highlighted package includes the feature you requested.",
"shop.recommendationTitle": "Recommended for you",
"shop.select": "Select",
"shop.status.active": "Active Plan",
"shop.status.owned": "Purchased",
"shop.status.remaining_one": "{{count}} Events left",
"shop.status.remaining_other": "{{count}} Events left",
"shop.subtitle": "Choose a package to unlock more features and limits.",
"shop.title": "Upgrade Package",
"sidebar_author_description": "__STRING_NOT_TRANSLATED__",

View File

@@ -944,9 +944,9 @@
"highlights.team.title": "Flexible team onboarding",
"home": {
"features_highlight": [
{},
{},
{}
null,
null,
null
],
"hero_bullets": [
null,
@@ -1040,9 +1040,9 @@
null
],
"steps": [
{},
{},
{}
null,
null,
null
]
},
"host": {
@@ -1052,34 +1052,34 @@
null
],
"steps": [
{},
{},
{}
null,
null,
null
]
}
},
"faq": {
"items": [
{},
{},
{},
{},
{},
{}
null,
null,
null,
null,
null,
null
]
},
"hero": {
"stats": [
{},
{},
{}
null,
null,
null
]
},
"pillars": [
{},
{},
{},
{}
null,
null,
null,
null
],
"timeline": [
{
@@ -2024,6 +2024,8 @@
"share_native": "__STRING_NOT_TRANSLATED__",
"share_title": "__STRING_NOT_TRANSLATED__",
"share_whatsapp": "__STRING_NOT_TRANSLATED__",
"shop.badges.active": "Active",
"shop.badges.recommended": "Recommended",
"shop.confirmSubtitle": "You are upgrading to:",
"shop.confirmTitle": "Confirm Purchase",
"shop.errors.checkout": "Checkout failed",
@@ -2035,9 +2037,16 @@
"shop.limits.photos_one": "{{count}} Photos",
"shop.limits.photos_other": "{{count}} Photos",
"shop.limits.unlimitedPhotos": "Unlimited Photos",
"shop.manage": "Manage Plan",
"shop.payNow": "Pay Now",
"shop.processing": "Processing...",
"shop.recommendationBody": "The highlighted package includes the feature you requested.",
"shop.recommendationTitle": "Recommended for you",
"shop.select": "Select",
"shop.status.active": "Active Plan",
"shop.status.owned": "Purchased",
"shop.status.remaining_one": "{{count}} Events left",
"shop.status.remaining_other": "{{count}} Events left",
"shop.subtitle": "Choose a package to unlock more features and limits.",
"shop.title": "Upgrade Package",
"sidebar_author_description": "__STRING_NOT_TRANSLATED__",

View File

@@ -66,7 +66,7 @@ export default function MobileEventAnalyticsPage() {
</YStack>
<CTAButton
label={t('analytics.upgradeAction', 'Upgrade to Premium')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
onPress={() => navigate(adminPath('/mobile/billing/shop?feature=advanced_analytics'))}
/>
</MobileCard>
</MobileShell>

View File

@@ -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<Package | null>(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 (
<MobileShell title={t('shop.title', 'Upgrade Package')} showBack activeTab="profile">
@@ -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 (
<MobileShell title={t('shop.title', 'Upgrade Package')} showBack activeTab="profile">
<YStack space="$4">
{recommendedFeature && (
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
<XStack space="$2" alignItems="center">
<Sparkles size={16} color={primary} />
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('shop.recommendationTitle', 'Recommended for you')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('shop.recommendationBody', 'The highlighted package includes the feature you requested.')}
</Text>
</MobileCard>
)}
<YStack paddingHorizontal="$2">
<Text fontSize="$sm" color={muted}>
{t('shop.subtitle', 'Choose a package to unlock more features and limits.')}
@@ -55,44 +94,84 @@ export default function MobilePackageShopPage() {
</YStack>
<YStack space="$3">
{packages?.map((pkg) => (
<PackageShopCard
key={pkg.id}
pkg={pkg}
onSelect={() => 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 (
<PackageShopCard
key={pkg.id}
pkg={pkg}
owned={owned}
isActive={isActive}
isRecommended={isRecommended}
onSelect={() => setSelectedPackage(pkg)}
/>
);
})}
</YStack>
</YStack>
</MobileShell>
);
}
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<string, boolean>, 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 (
<MobileCard
onPress={onSelect}
borderColor={border}
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
borderWidth={isRecommended || isActive ? 2 : 1}
space="$3"
pressStyle={{ backgroundColor: accentSoft }}
backgroundColor={isActive ? '$green1' : undefined}
>
<XStack justifyContent="space-between" alignItems="center">
<YStack>
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{pkg.name}
</Text>
<Text fontSize="$md" color={primary} fontWeight="700">
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
</Text>
<XStack justifyContent="space-between" alignItems="flex-start">
<YStack space="$1">
<XStack space="$2" alignItems="center">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{pkg.name}
</Text>
{isRecommended && <PillBadge tone="primary">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
</XStack>
<XStack space="$2" alignItems="center">
<Text fontSize="$md" color={primary} fontWeight="700">
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
</Text>
{statusLabel && (
<Text fontSize="$xs" color={muted} fontWeight="600">
{statusLabel}
</Text>
)}
</XStack>
</YStack>
<ChevronRight size={20} color={muted} />
<ChevronRight size={20} color={muted} marginTop="$2" />
</XStack>
<YStack space="$1.5">
@@ -104,9 +183,22 @@ function PackageShopCard({ pkg, onSelect }: { pkg: Package; onSelect: () => void
{pkg.gallery_days ? (
<FeatureRow label={t('shop.limits.days', '{{count}} Days Gallery', { count: 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]) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))
}
</YStack>
<CTAButton label={t('shop.select', 'Select')} onPress={onSelect} tone="primary" />
<CTAButton
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
onPress={onSelect}
tone={isActive ? 'ghost' : 'primary'}
/>
</MobileCard>
);
}