Explorar el Código

feat(billing): split billing into Plans and Top-ups submenus

- Create separate BillingPlans.tsx and BillingTopups.tsx pages
- Add collapsible Billing & Plans submenu in sidebar (like AI Settings)
- Add routes for /billing/plans and /billing/topups
- Improve Realtime subscription with separate UPDATE/INSERT handlers
- Add i18n translations for new billing pages (en, hu, de)
- Top-ups page shows usage summary and requires paid subscription

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fszontagh hace 4 meses
padre
commit
f7a9ca06b6

+ 5 - 1
shopcall.ai-main/src/App.tsx

@@ -43,6 +43,8 @@ const Products = lazy(() => import("./pages/Products"));
 const WebsiteContent = lazy(() => import("./pages/WebsiteContent"));
 const CustomContent = lazy(() => import("./pages/CustomContent"));
 const Billing = lazy(() => import("./pages/Billing"));
+const BillingPlans = lazy(() => import("./pages/BillingPlans"));
+const BillingTopups = lazy(() => import("./pages/BillingTopups"));
 
 // Loading component for lazy-loaded routes
 const PageLoader = () => <LoadingScreen />;
@@ -79,7 +81,9 @@ const App = () => (
                 <Route path="/api-keys" element={<APIKeys />} />
                 <Route path="/onboarding" element={<Onboarding />} />
                 <Route path="/settings" element={<Settings />} />
-                <Route path="/billing" element={<Billing />} />
+                <Route path="/billing" element={<BillingPlans />} />
+                <Route path="/billing/plans" element={<BillingPlans />} />
+                <Route path="/billing/topups" element={<BillingTopups />} />
 
                 {/* Pages that require an active store */}
                 <Route element={<StoreProtectedRoute />}>

+ 65 - 7
shopcall.ai-main/src/components/AppSidebar.tsx

@@ -48,11 +48,20 @@ export function AppSidebar() {
   const aiMenuPaths = ['/ai-config', '/products', '/website-content', '/custom-content'];
   const [isAIMenuOpen, setIsAIMenuOpen] = useState(aiMenuPaths.includes(currentPath));
 
+  // Keep Billing menu open when on any billing submenu page
+  const billingMenuPaths = ['/billing', '/billing/plans', '/billing/topups'];
+  const [isBillingMenuOpen, setIsBillingMenuOpen] = useState(billingMenuPaths.includes(currentPath));
+
   // Update AI menu state when path changes
   useEffect(() => {
     setIsAIMenuOpen(aiMenuPaths.includes(currentPath));
   }, [currentPath]);
 
+  // Update Billing menu state when path changes
+  useEffect(() => {
+    setIsBillingMenuOpen(billingMenuPaths.includes(currentPath));
+  }, [currentPath]);
+
   // Note: Stores are fetched by ShopContext on mount, no need to duplicate here
 
   const menuItems = [
@@ -94,11 +103,6 @@ export function AppSidebar() {
       icon: Settings,
       url: "/settings",
     },
-    {
-      title: "Billing & Plan",
-      icon: CreditCard,
-      url: "/billing",
-    },
   ];
 
   const handleShopChange = (shopId: string) => {
@@ -300,6 +304,60 @@ export function AppSidebar() {
           </SidebarGroupContent>
         </SidebarGroup>
 
+        {/* Billing & Plan Section */}
+        <SidebarGroup className="mt-4">
+          <div className="px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
+            {t('sidebar.billingSection')}
+          </div>
+          <SidebarGroupContent>
+            <SidebarMenu>
+              <Collapsible open={isBillingMenuOpen} onOpenChange={setIsBillingMenuOpen}>
+                <SidebarMenuItem>
+                  <CollapsibleTrigger asChild>
+                    <SidebarMenuButton className="w-full justify-start text-slate-300 hover:text-white hover:bg-slate-800/50">
+                      <div className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer w-full">
+                        <CreditCard className="w-4 h-4" />
+                        <span className="flex-1">{t('sidebar.billingMenu')}</span>
+                        <ChevronDown className={`w-4 h-4 transition-transform ${isBillingMenuOpen ? 'rotate-180' : ''}`} />
+                      </div>
+                    </SidebarMenuButton>
+                  </CollapsibleTrigger>
+                  <CollapsibleContent>
+                    <SidebarMenuSub>
+                      <SidebarMenuSubItem>
+                        <SidebarMenuSubButton
+                          asChild
+                          className={`text-slate-300 hover:text-white hover:bg-slate-800/50 ${
+                            currentPath === '/billing/plans' ? 'bg-cyan-500/20 text-cyan-400' : ''
+                          }`}
+                        >
+                          <a onClick={() => navigate('/billing/plans')} className="flex items-center gap-2 cursor-pointer">
+                            <CreditCard className="w-3 h-3" />
+                            <span>{t('sidebar.monthlyPlans')}</span>
+                          </a>
+                        </SidebarMenuSubButton>
+                      </SidebarMenuSubItem>
+                      <SidebarMenuSubItem>
+                        <SidebarMenuSubButton
+                          asChild
+                          className={`text-slate-300 hover:text-white hover:bg-slate-800/50 ${
+                            currentPath === '/billing/topups' ? 'bg-cyan-500/20 text-cyan-400' : ''
+                          }`}
+                        >
+                          <a onClick={() => navigate('/billing/topups')} className="flex items-center gap-2 cursor-pointer">
+                            <Zap className="w-3 h-3" />
+                            <span>{t('sidebar.topups')}</span>
+                          </a>
+                        </SidebarMenuSubButton>
+                      </SidebarMenuSubItem>
+                    </SidebarMenuSub>
+                  </CollapsibleContent>
+                </SidebarMenuItem>
+              </Collapsible>
+            </SidebarMenu>
+          </SidebarGroupContent>
+        </SidebarGroup>
+
         <SidebarGroup className="mt-4">
           <div className="px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
             {t('sidebar.configuration')}
@@ -338,9 +396,9 @@ export function AppSidebar() {
           {/* Subscription Usage Display */}
           {selectedShop && subscription && !subLoading && (
             <button
-              onClick={() => navigate('/billing')}
+              onClick={() => navigate('/billing/plans')}
               className={`p-3 w-full rounded-lg transition-colors cursor-pointer ${
-                currentPath === '/billing'
+                billingMenuPaths.includes(currentPath)
                   ? 'bg-cyan-500/20 border border-cyan-500/30'
                   : 'bg-slate-800/50 hover:bg-slate-800 border border-transparent'
               } ${isExpired ? 'border-red-500/50' : ''}`}

+ 18 - 3
shopcall.ai-main/src/hooks/useSubscription.ts

@@ -119,17 +119,30 @@ export function useSubscription(storeId: string | undefined): UseSubscriptionRet
       .on(
         'postgres_changes',
         {
-          event: '*',
+          event: 'UPDATE',
           schema: 'public',
           table: 'store_subscriptions',
           filter: `store_id=eq.${storeId}`
         },
         (payload) => {
-          console.log('Subscription update received:', payload);
+          console.log('Subscription UPDATE received:', payload);
           // Refetch to get full data with joins
           fetchSubscription();
         }
       )
+      .on(
+        'postgres_changes',
+        {
+          event: 'INSERT',
+          schema: 'public',
+          table: 'store_subscriptions',
+          filter: `store_id=eq.${storeId}`
+        },
+        (payload) => {
+          console.log('Subscription INSERT received:', payload);
+          fetchSubscription();
+        }
+      )
       .on(
         'postgres_changes',
         {
@@ -144,7 +157,9 @@ export function useSubscription(storeId: string | undefined): UseSubscriptionRet
           fetchSubscription();
         }
       )
-      .subscribe();
+      .subscribe((status) => {
+        console.log('Subscription channel status:', status);
+      });
 
     return () => {
       supabase.removeChannel(channel);

+ 23 - 2
shopcall.ai-main/src/i18n/locales/de.json

@@ -238,7 +238,11 @@
     "subscription": "Abonnement",
     "minutesUsed": "Verbrauchte Minuten",
     "min": "Min",
-    "daysLeft": "Tage übrig"
+    "daysLeft": "Tage übrig",
+    "billingSection": "Abrechnung",
+    "billingMenu": "Abrechnung & Tarife",
+    "monthlyPlans": "Monatliche Tarife",
+    "topups": "Aufladungen"
   },
   "settings": {
     "title": "Kontoeinstellungen",
@@ -1846,7 +1850,24 @@
     "trialInfo": "Die Testversion:",
     "trialInfoDesc": "Um Betrug zu verhindern und die Kosten für die Reservierung Ihrer Telefonnummer zu decken, benötigen wir eine Karte. Sie erhalten 10 Minuten kostenlos. Bei Überschreitung pausiert die KI bis zum Upgrade.",
     "overageInfo": "Überschreitung:",
-    "overageInfoDesc": "Bei Überschreitung der monatlichen Minuten antworten wir weiter und berechnen zum \"Überschreitungstarif\". Sie können in den Einstellungen ein Limit setzen."
+    "overageInfoDesc": "Bei Überschreitung der monatlichen Minuten antworten wir weiter und berechnen zum \"Überschreitungstarif\". Sie können in den Einstellungen ein Limit setzen.",
+    "vat": "MwSt",
+    "topups": "Minuten-Aufladungen",
+    "topupsSubtitle": "Kaufen Sie zusätzliche Minuten, um Ihr monatliches Kontingent zu erweitern.",
+    "currentUsage": "Aktuelle Nutzung",
+    "includedMinutes": "Enthaltene Minuten",
+    "packageMinutes": "Paket-Minuten",
+    "totalRemaining": "Gesamt verbleibend",
+    "used": "verbraucht",
+    "included": "enthalten",
+    "upgradeRequired": "Upgrade erforderlich",
+    "upgradeRequiredDesc": "Aufladepakete sind nur für kostenpflichtige Abonnements verfügbar. Wechseln Sie zu einem kostenpflichtigen Tarif, um zusätzliche Minuten zu kaufen.",
+    "viewPlans": "Tarife anzeigen",
+    "topupsInfo": "Über Aufladungen",
+    "packageExpiry": "Paketablauf:",
+    "packageExpiryDesc": "Gekaufte Pakete laufen am Ende des aktuellen Abrechnungszeitraums ab.",
+    "usageOrder": "Nutzungsreihenfolge:",
+    "usageOrderDesc": "Zuerst werden die im Tarif enthaltenen Minuten verbraucht, dann die Paket-Minuten, dann gelten die Überschreitungstarife."
   },
   "crawler": {
     "title": "ShopCall.ai Web-Crawler",

+ 23 - 2
shopcall.ai-main/src/i18n/locales/en.json

@@ -238,7 +238,11 @@
     "subscription": "Subscription",
     "minutesUsed": "Minutes used",
     "min": "min",
-    "daysLeft": "days left"
+    "daysLeft": "days left",
+    "billingSection": "Billing",
+    "billingMenu": "Billing & Plans",
+    "monthlyPlans": "Monthly Plans",
+    "topups": "Top-ups"
   },
   "settings": {
     "title": "Account Settings",
@@ -2149,7 +2153,24 @@
     "trialInfo": "The Trial:",
     "trialInfoDesc": "To prevent fraud and cover the cost of reserving your phone number, we require a card on file. You get 10 minutes free. If you exceed 10 minutes, the AI pauses until upgrade.",
     "overageInfo": "Overage:",
-    "overageInfoDesc": "If you exceed monthly minutes, we continue answering and bill at the \"Overage Rate\". You can set a hard cap in settings."
+    "overageInfoDesc": "If you exceed monthly minutes, we continue answering and bill at the \"Overage Rate\". You can set a hard cap in settings.",
+    "vat": "VAT",
+    "topups": "Minute Top-ups",
+    "topupsSubtitle": "Purchase additional minutes to extend your monthly quota.",
+    "currentUsage": "Current Usage",
+    "includedMinutes": "Included Minutes",
+    "packageMinutes": "Package Minutes",
+    "totalRemaining": "Total Remaining",
+    "used": "used",
+    "included": "included",
+    "upgradeRequired": "Upgrade Required",
+    "upgradeRequiredDesc": "Top-up packages are only available for paid subscriptions. Upgrade to a paid plan to purchase additional minutes.",
+    "viewPlans": "View Plans",
+    "topupsInfo": "About Top-ups",
+    "packageExpiry": "Package Expiry:",
+    "packageExpiryDesc": "Purchased packages expire at the end of your current billing period.",
+    "usageOrder": "Usage Order:",
+    "usageOrderDesc": "Included plan minutes are used first, then package minutes, then overage rates apply."
   },
   "crawler": {
     "title": "ShopCall.ai Web Crawler",

+ 23 - 2
shopcall.ai-main/src/i18n/locales/hu.json

@@ -238,7 +238,11 @@
     "subscription": "Előfizetés",
     "minutesUsed": "Felhasznált percek",
     "min": "perc",
-    "daysLeft": "nap van hátra"
+    "daysLeft": "nap van hátra",
+    "billingSection": "Számlázás",
+    "billingMenu": "Számlázás & Csomagok",
+    "monthlyPlans": "Havi Csomagok",
+    "topups": "Feltöltések"
   },
   "settings": {
     "title": "Fiókbeállítások",
@@ -2027,7 +2031,24 @@
     "trialInfo": "A próbaidőszak:",
     "trialInfoDesc": "A csalás megelőzése és a telefonszám foglalási költségének fedezése érdekében bankkártyát kérünk. 10 perc ingyenes. Ha túlléped a 10 percet, az AI szünetel a frissítésig.",
     "overageInfo": "Túlhasználat:",
-    "overageInfoDesc": "Ha túlléped a havi perceket, továbbra is válaszolunk és a \"Túlhasználati díj\" szerint számlázzuk. A beállításokban korlátozhatod."
+    "overageInfoDesc": "Ha túlléped a havi perceket, továbbra is válaszolunk és a \"Túlhasználati díj\" szerint számlázzuk. A beállításokban korlátozhatod.",
+    "vat": "ÁFA",
+    "topups": "Perc feltöltések",
+    "topupsSubtitle": "Vásárolj további perceket a havi keretedhez.",
+    "currentUsage": "Aktuális felhasználás",
+    "includedMinutes": "Benne foglalt percek",
+    "packageMinutes": "Csomag percek",
+    "totalRemaining": "Összes hátralévő",
+    "used": "felhasználva",
+    "included": "benne foglalt",
+    "upgradeRequired": "Frissítés szükséges",
+    "upgradeRequiredDesc": "A feltöltési csomagok csak fizetős előfizetésekhez érhetők el. Frissíts fizetős csomagra további percek vásárlásához.",
+    "viewPlans": "Csomagok megtekintése",
+    "topupsInfo": "A feltöltésekről",
+    "packageExpiry": "Csomag lejárat:",
+    "packageExpiryDesc": "A megvásárolt csomagok a jelenlegi számlázási időszak végén lejárnak.",
+    "usageOrder": "Felhasználási sorrend:",
+    "usageOrderDesc": "Először a csomagban foglalt percek használódnak el, majd a feltöltött percek, végül a túlhasználati díjak lépnek érvénybe."
   },
   "crawler": {
     "title": "ShopCall.ai Web Crawler",

+ 318 - 0
shopcall.ai-main/src/pages/BillingPlans.tsx

@@ -0,0 +1,318 @@
+import { useState } from "react";
+import { SidebarProvider } from "@/components/ui/sidebar";
+import { AppSidebar } from "@/components/AppSidebar";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { useTranslation } from 'react-i18next';
+import { useShop } from "@/components/context/ShopContext";
+import { useSubscription, useUpdateSubscription } from "@/hooks/useSubscription";
+import { usePlans } from "@/hooks/usePlans";
+import {
+  Zap,
+  Star,
+  TrendingUp,
+  Crown,
+  Check,
+  AlertCircle,
+  Loader2,
+  Info
+} from "lucide-react";
+import {
+  getPlanPrice,
+  formatCurrency,
+  type Currency,
+  type PlanWithTranslation
+} from "@/types/subscription.types";
+
+// Icon mapping for plans
+const planIcons: Record<string, React.ElementType> = {
+  'Zap': Zap,
+  'Star': Star,
+  'TrendingUp': TrendingUp,
+  'Crown': Crown
+};
+
+export default function BillingPlans() {
+  const { t } = useTranslation();
+  const { selectedShop } = useShop();
+  const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
+  const [isChangingPlan, setIsChangingPlan] = useState(false);
+
+  // Hooks
+  const {
+    subscription,
+    isLoading: subLoading,
+    refetch: refetchSubscription
+  } = useSubscription(selectedShop?.id);
+
+  const { plans, isLoading: plansLoading } = usePlans();
+  const { changePlan, isUpdating } = useUpdateSubscription();
+
+  const currentCurrency: Currency = (subscription?.currency as Currency) || 'HUF';
+  const isLoading = subLoading || plansLoading;
+
+  // Handle plan selection
+  const handleSelectPlan = async (plan: PlanWithTranslation) => {
+    if (!selectedShop?.id || isUpdating) return;
+
+    // Don't allow selecting trial if already used
+    if (plan.is_trial && subscription && subscription.status !== 'trial') {
+      return;
+    }
+
+    // Don't allow selecting current plan
+    if (subscription?.plan_id === plan.id) {
+      return;
+    }
+
+    setSelectedPlanId(plan.id);
+    setIsChangingPlan(true);
+
+    const success = await changePlan(selectedShop.id, plan.id, plan);
+    if (success) {
+      await refetchSubscription();
+    }
+
+    setIsChangingPlan(false);
+    setSelectedPlanId(null);
+  };
+
+  // Render loading state
+  if (isLoading) {
+    return (
+      <SidebarProvider>
+        <div className="min-h-screen flex w-full bg-slate-900">
+          <AppSidebar />
+          <main className="flex-1 bg-slate-900 text-white flex items-center justify-center">
+            <Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
+          </main>
+        </div>
+      </SidebarProvider>
+    );
+  }
+
+  // No shop selected
+  if (!selectedShop) {
+    return (
+      <SidebarProvider>
+        <div className="min-h-screen flex w-full bg-slate-900">
+          <AppSidebar />
+          <main className="flex-1 bg-slate-900 text-white p-6">
+            <div className="text-center py-20">
+              <AlertCircle className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
+              <h2 className="text-2xl font-bold mb-2">{t('billing.noShopSelected')}</h2>
+              <p className="text-slate-400">{t('billing.selectShopFirst')}</p>
+            </div>
+          </main>
+        </div>
+      </SidebarProvider>
+    );
+  }
+
+  return (
+    <SidebarProvider>
+      <div className="min-h-screen flex w-full bg-slate-900">
+        <AppSidebar />
+        <main className="flex-1 bg-slate-900 text-white">
+          <div className="flex flex-col gap-6 p-6">
+            {/* Header */}
+            <div>
+              <h1 className="text-3xl font-bold text-white">{t('billing.chooseYourPlan')}</h1>
+              <p className="text-slate-400 mt-2">
+                {t('billing.subtitle')}
+              </p>
+            </div>
+
+            {/* Plan Cards Grid */}
+            <div className="space-y-6">
+              {/* Free Trial Card - Separate */}
+              {plans.filter(p => p.is_trial).map(plan => {
+                const Icon = planIcons[plan.icon_name || 'Zap'] || Zap;
+                const isCurrentPlan = subscription?.plan_id === plan.id;
+                const isDisabled = subscription && subscription.status !== 'trial';
+
+                return (
+                  <Card
+                    key={plan.id}
+                    className={`relative border-2 border-dashed transition-all cursor-pointer ${
+                      isCurrentPlan
+                        ? 'border-cyan-500 bg-slate-800/80'
+                        : isDisabled
+                        ? 'border-slate-700 bg-slate-800/30 opacity-50 cursor-not-allowed'
+                        : 'border-slate-600 bg-slate-800/50 hover:border-cyan-500/50'
+                    }`}
+                    onClick={() => !isDisabled && handleSelectPlan(plan)}
+                  >
+                    <CardContent className="p-6">
+                      <div className="flex items-center justify-between">
+                        <div className="flex items-center gap-4">
+                          <div className="w-12 h-12 rounded-lg bg-slate-700/50 flex items-center justify-center">
+                            <Icon className="w-6 h-6 text-yellow-400" />
+                          </div>
+                          <div>
+                            <div className="flex items-center gap-2">
+                              <Zap className="w-4 h-4 text-yellow-400" />
+                              <span className="font-semibold text-white">
+                                {plan.translations.name}
+                              </span>
+                            </div>
+                            <p className="text-sm text-slate-400 mt-1">
+                              {plan.translations.description}
+                            </p>
+                          </div>
+                        </div>
+                        <div className="flex items-center gap-4">
+                          <div className="text-right">
+                            <div className="text-2xl font-bold text-white">
+                              {formatCurrency(getPlanPrice(plan, currentCurrency), currentCurrency)}
+                            </div>
+                            <div className="text-sm text-slate-400">
+                              {plan.translations.period_label}
+                            </div>
+                          </div>
+                          <div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
+                            isCurrentPlan ? 'border-cyan-500 bg-cyan-500' : 'border-slate-600'
+                          }`}>
+                            {isCurrentPlan && <Check className="w-4 h-4 text-white" />}
+                          </div>
+                        </div>
+                      </div>
+                    </CardContent>
+                  </Card>
+                );
+              })}
+
+              {/* Paid Plans Grid */}
+              <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
+                {plans.filter(p => !p.is_trial).map(plan => {
+                  const Icon = planIcons[plan.icon_name || 'Star'] || Star;
+                  const isCurrentPlan = subscription?.plan_id === plan.id;
+                  const isSelected = selectedPlanId === plan.id;
+
+                  return (
+                    <Card
+                      key={plan.id}
+                      className={`relative transition-all cursor-pointer ${
+                        plan.is_popular
+                          ? 'border-2 border-cyan-500 bg-slate-800'
+                          : isCurrentPlan
+                          ? 'border-2 border-cyan-500/50 bg-slate-800'
+                          : 'border border-slate-700 bg-slate-800/50 hover:border-slate-600'
+                      }`}
+                      onClick={() => handleSelectPlan(plan)}
+                    >
+                      {/* Most Popular Badge */}
+                      {plan.translations.badge_text && (
+                        <div className="absolute -top-3 left-1/2 transform -translate-x-1/2 z-10">
+                          <Badge className="bg-cyan-500 text-white px-3 py-1 text-xs font-semibold">
+                            <Star className="w-3 h-3 mr-1 inline" />
+                            {plan.translations.badge_text}
+                          </Badge>
+                        </div>
+                      )}
+
+                      <CardHeader className="pt-6 pb-2">
+                        <CardTitle className="text-lg text-white font-semibold">
+                          {plan.translations.name}
+                        </CardTitle>
+                        <p className="text-sm text-slate-400">
+                          {plan.translations.description}
+                        </p>
+                      </CardHeader>
+
+                      <CardContent className="space-y-4">
+                        {/* Price */}
+                        <div className="flex items-baseline gap-1">
+                          <span className="text-3xl font-bold text-white">
+                            {formatCurrency(getPlanPrice(plan, currentCurrency), currentCurrency).replace(/[^\d,.\s]/g, '')}
+                          </span>
+                          <span className="text-lg text-slate-400">
+                            {currentCurrency === 'HUF' ? ' Ft' : currentCurrency === 'EUR' ? '€' : '$'}
+                          </span>
+                          <span className="text-slate-400">{plan.translations.period_label}</span>
+                        </div>
+
+                        <p className="text-xs text-slate-500">+ {t('billing.vat')}</p>
+
+                        {/* Features List */}
+                        <div className="space-y-3 pt-2">
+                          {plan.features.map((feature) => (
+                            <div key={feature.id} className="flex items-start gap-2">
+                              <Check className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
+                                feature.is_highlighted ? 'text-cyan-400' : 'text-green-500'
+                              }`} />
+                              <span className={`text-sm ${
+                                feature.is_highlighted
+                                  ? 'text-white font-medium'
+                                  : 'text-slate-300'
+                              }`}>
+                                {feature.translations.label}
+                              </span>
+                            </div>
+                          ))}
+                        </div>
+
+                        {/* Overage Rate */}
+                        {plan.translations.overage_label && (
+                          <div className="pt-4 border-t border-slate-700">
+                            <p className="text-sm text-slate-400">
+                              {plan.translations.overage_label}
+                            </p>
+                          </div>
+                        )}
+
+                        {/* Select Button */}
+                        <Button
+                          className={`w-full mt-4 ${
+                            isCurrentPlan
+                              ? 'bg-slate-700 text-slate-300 cursor-default'
+                              : plan.is_popular
+                              ? 'bg-cyan-500 hover:bg-cyan-600 text-white'
+                              : 'bg-slate-700 hover:bg-slate-600 text-white'
+                          }`}
+                          disabled={isCurrentPlan || isChangingPlan}
+                        >
+                          {isSelected && isChangingPlan ? (
+                            <Loader2 className="w-4 h-4 animate-spin mr-2" />
+                          ) : null}
+                          {isCurrentPlan
+                            ? t('billing.currentPlan')
+                            : t('billing.selectPlan')}
+                        </Button>
+                      </CardContent>
+                    </Card>
+                  );
+                })}
+              </div>
+            </div>
+
+            {/* How Billing Works Section */}
+            <Card className="border border-slate-700 bg-slate-800/50 mt-4">
+              <CardContent className="p-6">
+                <h3 className="font-semibold text-white mb-4 flex items-center gap-2">
+                  <Info className="w-5 h-5 text-cyan-400" />
+                  {t('billing.howBillingWorks')}
+                </h3>
+                <div className="space-y-3 text-sm text-slate-400">
+                  <p>
+                    <strong className="text-slate-300">{t('billing.phoneNumberInfo')}</strong>{' '}
+                    {t('billing.phoneNumberInfoDesc')}
+                  </p>
+                  <p>
+                    <strong className="text-slate-300">{t('billing.trialInfo')}</strong>{' '}
+                    {t('billing.trialInfoDesc')}
+                  </p>
+                  <p>
+                    <strong className="text-slate-300">{t('billing.overageInfo')}</strong>{' '}
+                    {t('billing.overageInfoDesc')}
+                  </p>
+                </div>
+              </CardContent>
+            </Card>
+          </div>
+        </main>
+      </div>
+    </SidebarProvider>
+  );
+}

+ 388 - 0
shopcall.ai-main/src/pages/BillingTopups.tsx

@@ -0,0 +1,388 @@
+import { SidebarProvider } from "@/components/ui/sidebar";
+import { AppSidebar } from "@/components/AppSidebar";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useTranslation } from 'react-i18next';
+import { useShop } from "@/components/context/ShopContext";
+import { useSubscription, useUpdateSubscription } from "@/hooks/useSubscription";
+import { usePackages } from "@/hooks/usePlans";
+import {
+  Clock,
+  AlertCircle,
+  Loader2,
+  Info,
+  Package,
+  Zap,
+  AlertTriangle
+} from "lucide-react";
+import {
+  getPackagePrice,
+  formatCurrency,
+  getUsageProgressColor,
+  type Currency,
+  type PackageWithTranslation
+} from "@/types/subscription.types";
+import { useNavigate } from "react-router-dom";
+
+export default function BillingTopups() {
+  const { t } = useTranslation();
+  const { selectedShop } = useShop();
+  const navigate = useNavigate();
+
+  // Hooks
+  const {
+    subscription,
+    isLoading: subLoading,
+    minutesRemaining,
+    packageMinutesRemaining,
+    totalMinutesRemaining,
+    usagePercentage,
+    isExpired,
+    daysRemaining,
+    refetch: refetchSubscription
+  } = useSubscription(selectedShop?.id);
+
+  const { packages, purchasePackage, isLoading: packagesLoading } = usePackages();
+  const { updateAutoPurchase, isUpdating } = useUpdateSubscription();
+
+  const currentCurrency: Currency = (subscription?.currency as Currency) || 'HUF';
+  const isLoading = subLoading || packagesLoading;
+
+  // Handle package purchase
+  const handlePurchasePackage = async (pkg: PackageWithTranslation) => {
+    if (!selectedShop?.id || !subscription?.id) return;
+
+    const success = await purchasePackage(
+      selectedShop.id,
+      subscription.id,
+      pkg.id,
+      pkg,
+      currentCurrency
+    );
+
+    if (success) {
+      await refetchSubscription();
+    }
+  };
+
+  // Handle auto-purchase toggle
+  const handleAutoPurchaseToggle = async (enabled: boolean) => {
+    if (!selectedShop?.id) return;
+
+    await updateAutoPurchase(
+      selectedShop.id,
+      enabled,
+      subscription?.auto_purchase_package_id || packages[0]?.id,
+      subscription?.auto_purchase_threshold || 5
+    );
+
+    await refetchSubscription();
+  };
+
+  // Render loading state
+  if (isLoading) {
+    return (
+      <SidebarProvider>
+        <div className="min-h-screen flex w-full bg-slate-900">
+          <AppSidebar />
+          <main className="flex-1 bg-slate-900 text-white flex items-center justify-center">
+            <Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
+          </main>
+        </div>
+      </SidebarProvider>
+    );
+  }
+
+  // No shop selected
+  if (!selectedShop) {
+    return (
+      <SidebarProvider>
+        <div className="min-h-screen flex w-full bg-slate-900">
+          <AppSidebar />
+          <main className="flex-1 bg-slate-900 text-white p-6">
+            <div className="text-center py-20">
+              <AlertCircle className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
+              <h2 className="text-2xl font-bold mb-2">{t('billing.noShopSelected')}</h2>
+              <p className="text-slate-400">{t('billing.selectShopFirst')}</p>
+            </div>
+          </main>
+        </div>
+      </SidebarProvider>
+    );
+  }
+
+  // Show upgrade prompt for trial users
+  if (subscription?.status === 'trial') {
+    return (
+      <SidebarProvider>
+        <div className="min-h-screen flex w-full bg-slate-900">
+          <AppSidebar />
+          <main className="flex-1 bg-slate-900 text-white p-6">
+            <div className="flex flex-col gap-6">
+              {/* Header */}
+              <div>
+                <h1 className="text-3xl font-bold text-white">{t('billing.topups')}</h1>
+                <p className="text-slate-400 mt-2">
+                  {t('billing.topupsSubtitle')}
+                </p>
+              </div>
+
+              {/* Upgrade Required */}
+              <Card className="border border-yellow-500/50 bg-yellow-500/10">
+                <CardContent className="p-8 text-center">
+                  <AlertTriangle className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
+                  <h2 className="text-2xl font-bold text-white mb-2">
+                    {t('billing.upgradeRequired')}
+                  </h2>
+                  <p className="text-slate-400 mb-6 max-w-md mx-auto">
+                    {t('billing.upgradeRequiredDesc')}
+                  </p>
+                  <Button
+                    className="bg-cyan-500 hover:bg-cyan-600 text-white"
+                    onClick={() => navigate('/billing/plans')}
+                  >
+                    {t('billing.viewPlans')}
+                  </Button>
+                </CardContent>
+              </Card>
+            </div>
+          </main>
+        </div>
+      </SidebarProvider>
+    );
+  }
+
+  return (
+    <SidebarProvider>
+      <div className="min-h-screen flex w-full bg-slate-900">
+        <AppSidebar />
+        <main className="flex-1 bg-slate-900 text-white">
+          <div className="flex flex-col gap-6 p-6">
+            {/* Header */}
+            <div>
+              <h1 className="text-3xl font-bold text-white">{t('billing.topups')}</h1>
+              <p className="text-slate-400 mt-2">
+                {t('billing.topupsSubtitle')}
+              </p>
+            </div>
+
+            {/* Current Usage Summary */}
+            <Card className="border border-slate-700 bg-slate-800/50">
+              <CardContent className="p-6">
+                <div className="flex items-center justify-between mb-4">
+                  <h3 className="font-semibold text-white flex items-center gap-2">
+                    <Clock className="w-5 h-5 text-cyan-400" />
+                    {t('billing.currentUsage')}
+                  </h3>
+                  <span className="text-sm text-slate-400">
+                    {subscription?.plan_translation?.name || subscription?.plan?.plan_code}
+                  </span>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
+                  <div className="p-4 bg-slate-700/50 rounded-lg">
+                    <div className="text-sm text-slate-400 mb-1">{t('billing.includedMinutes')}</div>
+                    <div className="text-2xl font-bold text-white">
+                      {Math.round(minutesRemaining)} / {subscription?.minutes_included}
+                      <span className="text-sm font-normal text-slate-400 ml-1">{t('sidebar.min')}</span>
+                    </div>
+                  </div>
+                  <div className="p-4 bg-slate-700/50 rounded-lg">
+                    <div className="text-sm text-slate-400 mb-1">{t('billing.packageMinutes')}</div>
+                    <div className="text-2xl font-bold text-cyan-400">
+                      {Math.round(packageMinutesRemaining)}
+                      <span className="text-sm font-normal text-slate-400 ml-1">{t('sidebar.min')}</span>
+                    </div>
+                  </div>
+                  <div className="p-4 bg-slate-700/50 rounded-lg">
+                    <div className="text-sm text-slate-400 mb-1">{t('billing.totalRemaining')}</div>
+                    <div className={`text-2xl font-bold ${
+                      totalMinutesRemaining <= 5 ? 'text-red-400' :
+                      totalMinutesRemaining <= 15 ? 'text-yellow-400' :
+                      'text-green-400'
+                    }`}>
+                      {Math.round(totalMinutesRemaining)}
+                      <span className="text-sm font-normal text-slate-400 ml-1">{t('sidebar.min')}</span>
+                    </div>
+                  </div>
+                </div>
+
+                {/* Progress bar */}
+                <div className="h-2 bg-slate-700 rounded-full overflow-hidden">
+                  <div
+                    className={`h-full transition-all duration-300 ${getUsageProgressColor(usagePercentage)}`}
+                    style={{ width: `${Math.min(usagePercentage, 100)}%` }}
+                  />
+                </div>
+                <div className="flex justify-between text-xs text-slate-500 mt-1">
+                  <span>{Math.round(subscription?.minutes_used || 0)} {t('billing.used')}</span>
+                  <span>{subscription?.minutes_included} {t('billing.included')}</span>
+                </div>
+              </CardContent>
+            </Card>
+
+            {/* Additional Packages Section */}
+            <div>
+              <h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
+                <Package className="w-5 h-5 text-cyan-400" />
+                {t('billing.additionalPackages')}
+              </h2>
+              <p className="text-slate-400 mb-6">
+                {t('billing.additionalPackagesDescription')}
+              </p>
+
+              <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+                {packages.map(pkg => (
+                  <Card
+                    key={pkg.id}
+                    className="border border-slate-700 bg-slate-800/50 hover:border-cyan-500/50 transition-all"
+                  >
+                    <CardContent className="p-6">
+                      <div className="flex items-center gap-3 mb-4">
+                        <div className="w-12 h-12 rounded-lg bg-cyan-500/20 flex items-center justify-center">
+                          <Zap className="w-6 h-6 text-cyan-400" />
+                        </div>
+                        <div>
+                          <span className="font-semibold text-white text-lg">
+                            {pkg.translations.name}
+                          </span>
+                          <div className="text-sm text-slate-400">
+                            {pkg.minutes} {t('sidebar.min')}
+                          </div>
+                        </div>
+                      </div>
+
+                      <p className="text-sm text-slate-400 mb-4">
+                        {pkg.translations.description}
+                      </p>
+
+                      <div className="flex items-center justify-between mb-4">
+                        <span className="text-2xl font-bold text-white">
+                          {formatCurrency(getPackagePrice(pkg, currentCurrency), currentCurrency)}
+                        </span>
+                        <span className="text-xs text-slate-500">+ {t('billing.vat')}</span>
+                      </div>
+
+                      <Button
+                        className="w-full bg-cyan-500 hover:bg-cyan-600 text-white"
+                        onClick={() => handlePurchasePackage(pkg)}
+                      >
+                        {t('billing.purchasePackage')}
+                      </Button>
+                    </CardContent>
+                  </Card>
+                ))}
+              </div>
+            </div>
+
+            {/* Auto-Purchase Settings */}
+            <Card className="border border-slate-700 bg-slate-800/50">
+              <CardContent className="p-6">
+                <div className="flex items-center justify-between">
+                  <div>
+                    <h3 className="font-semibold text-white flex items-center gap-2">
+                      <Zap className="w-5 h-5 text-yellow-400" />
+                      {t('billing.autoPurchase')}
+                    </h3>
+                    <p className="text-sm text-slate-400 mt-1">
+                      {t('billing.autoPurchaseDescription')}
+                    </p>
+                  </div>
+                  <Switch
+                    checked={subscription?.auto_purchase_enabled || false}
+                    onCheckedChange={handleAutoPurchaseToggle}
+                  />
+                </div>
+
+                {subscription?.auto_purchase_enabled && (
+                  <div className="mt-4 pt-4 border-t border-slate-700 grid grid-cols-1 md:grid-cols-2 gap-4">
+                    <div>
+                      <label className="text-sm text-slate-400 mb-2 block">
+                        {t('billing.autoPurchasePackage')}
+                      </label>
+                      <Select
+                        value={subscription.auto_purchase_package_id || ''}
+                        onValueChange={(value) => {
+                          if (selectedShop?.id) {
+                            updateAutoPurchase(
+                              selectedShop.id,
+                              true,
+                              value,
+                              subscription.auto_purchase_threshold
+                            );
+                          }
+                        }}
+                      >
+                        <SelectTrigger className="bg-slate-700 border-slate-600">
+                          <SelectValue />
+                        </SelectTrigger>
+                        <SelectContent className="bg-slate-800 border-slate-600">
+                          {packages.map(pkg => (
+                            <SelectItem key={pkg.id} value={pkg.id} className="text-white">
+                              {pkg.translations.name} - {formatCurrency(getPackagePrice(pkg, currentCurrency), currentCurrency)}
+                            </SelectItem>
+                          ))}
+                        </SelectContent>
+                      </Select>
+                    </div>
+                    <div>
+                      <label className="text-sm text-slate-400 mb-2 block">
+                        {t('billing.autoPurchaseThreshold')}
+                      </label>
+                      <Select
+                        value={String(subscription.auto_purchase_threshold)}
+                        onValueChange={(value) => {
+                          if (selectedShop?.id) {
+                            updateAutoPurchase(
+                              selectedShop.id,
+                              true,
+                              subscription.auto_purchase_package_id || packages[0]?.id,
+                              parseInt(value)
+                            );
+                          }
+                        }}
+                      >
+                        <SelectTrigger className="bg-slate-700 border-slate-600">
+                          <SelectValue />
+                        </SelectTrigger>
+                        <SelectContent className="bg-slate-800 border-slate-600">
+                          {[1, 2, 5, 10, 15, 20].map(mins => (
+                            <SelectItem key={mins} value={String(mins)} className="text-white">
+                              {mins} {t('billing.minutesRemaining')}
+                            </SelectItem>
+                          ))}
+                        </SelectContent>
+                      </Select>
+                    </div>
+                  </div>
+                )}
+              </CardContent>
+            </Card>
+
+            {/* Info Section */}
+            <Card className="border border-slate-700 bg-slate-800/50">
+              <CardContent className="p-6">
+                <h3 className="font-semibold text-white mb-4 flex items-center gap-2">
+                  <Info className="w-5 h-5 text-cyan-400" />
+                  {t('billing.topupsInfo')}
+                </h3>
+                <div className="space-y-3 text-sm text-slate-400">
+                  <p>
+                    <strong className="text-slate-300">{t('billing.packageExpiry')}</strong>{' '}
+                    {t('billing.packageExpiryDesc')}
+                  </p>
+                  <p>
+                    <strong className="text-slate-300">{t('billing.usageOrder')}</strong>{' '}
+                    {t('billing.usageOrderDesc')}
+                  </p>
+                </div>
+              </CardContent>
+            </Card>
+          </div>
+        </main>
+      </div>
+    </SidebarProvider>
+  );
+}