Przeglądaj źródła

feat: implement ShopRenter integration flow on integrations page #97

- Update oauth-shoprenter-callback to exchange code for tokens and store in pending_shoprenter_installs table
- Add HTTP/1.0 request handler for ShopRenter API (required by their API)
- Create complete-shoprenter-install Edge Function to finalize store creation
- Create IntegrationsRedirect page to handle ShopRenter OAuth callback
- Support both authenticated and non-authenticated users with auth flow
- Update App.tsx routing to add /integrations route
- Update IntegrationsContent to handle ShopRenter connected callback
- Store pending installation with tokens, phone_number_id, and expiration
- Clean up pending installation after successful store creation
- Create default sync configuration for new stores

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

Co-Authored-By: Claude <noreply@anthropic.com>
Claude 5 miesięcy temu
rodzic
commit
3e6f8061e4

+ 2 - 0
shopcall.ai-main/src/App.tsx

@@ -23,6 +23,7 @@ import Terms from "./pages/Terms";
 import Contact from "./pages/Contact";
 import NotFound from "./pages/NotFound";
 import ShopRenterIntegration from "./pages/ShopRenterIntegration";
+import IntegrationsRedirect from "./pages/IntegrationsRedirect";
 import { AuthProvider } from "./components/context/AuthContext";
 import PrivateRoute from "./components/PrivateRoute";
 
@@ -54,6 +55,7 @@ const App = () => (
             <Route path="/about" element={<About />} />
             <Route path="/privacy" element={<Privacy />} />
             <Route path="/terms" element={<Terms />} />
+            <Route path="/integrations" element={<IntegrationsRedirect />} />
             <Route path="/integrations/shoprenter" element={<ShopRenterIntegration />} />
             {/*<Route path="/contact" element={<Contact />} />*/}
             {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

+ 15 - 0
shopcall.ai-main/src/components/IntegrationsContent.tsx

@@ -171,6 +171,21 @@ export function IntegrationsContent() {
       fetchStores();
     }
 
+    // Handle ShopRenter OAuth callback
+    if (params.get('sr_connected') === 'true') {
+      const storeName = params.get('store');
+      toast({
+        title: t('integrations.oauth.shoprenterConnected', 'ShopRenter Connected'),
+        description: storeName
+          ? t('integrations.oauth.shoprenterConnectedDescription', { storeName: decodeURIComponent(storeName) })
+          : t('integrations.oauth.shoprenterConnectedDefault', 'Your ShopRenter store has been connected successfully!'),
+      });
+      // Clean up URL
+      window.history.replaceState({}, '', '/webshops');
+      // Refresh stores list
+      fetchStores();
+    }
+
     // Handle errors
     const error = params.get('error');
     if (error) {

+ 343 - 0
shopcall.ai-main/src/pages/IntegrationsRedirect.tsx

@@ -0,0 +1,343 @@
+import { useState, useEffect } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Store, LogIn, UserPlus, Loader2, CheckCircle, AlertCircle, ArrowRight } from "lucide-react";
+import { useAuth } from "@/components/context/AuthContext";
+import { API_URL } from "@/lib/config";
+import { useToast } from "@/hooks/use-toast";
+import { useTranslation } from "react-i18next";
+
+interface PendingInstallation {
+  installation_id: string;
+  shopname: string;
+  platform: 'shoprenter' | 'shopify' | 'woocommerce';
+}
+
+export default function IntegrationsRedirect() {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+  const { isAuthenticated, loading: authLoading } = useAuth();
+  const { toast } = useToast();
+
+  const [pendingInstall, setPendingInstall] = useState<PendingInstallation | null>(null);
+  const [showAuthDialog, setShowAuthDialog] = useState(false);
+  const [showAssignDialog, setShowAssignDialog] = useState(false);
+  const [completing, setCompleting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  // Detect platform from URL parameters
+  useEffect(() => {
+    const srInstall = searchParams.get('sr_install');
+    const shopname = searchParams.get('shopname');
+    const errorParam = searchParams.get('error');
+
+    // Handle errors
+    if (errorParam) {
+      setError(errorParam === 'token_exchange_failed'
+        ? t('integrations.oauth.errors.token_exchange_failed', 'Failed to exchange token with ShopRenter. Please try again.')
+        : t(`integrations.oauth.errors.${errorParam}`, errorParam));
+      return;
+    }
+
+    // Detect ShopRenter installation
+    if (srInstall && shopname) {
+      setPendingInstall({
+        installation_id: srInstall,
+        shopname: shopname,
+        platform: 'shoprenter'
+      });
+    }
+  }, [searchParams, t]);
+
+  // Handle auth state changes
+  useEffect(() => {
+    if (authLoading) return;
+
+    if (pendingInstall) {
+      if (isAuthenticated) {
+        // User is logged in, show assign dialog
+        setShowAssignDialog(true);
+      } else {
+        // User is not logged in, show auth options
+        setShowAuthDialog(true);
+      }
+    }
+  }, [isAuthenticated, authLoading, pendingInstall]);
+
+  // Complete the installation
+  const completeInstallation = async () => {
+    if (!pendingInstall) return;
+
+    setCompleting(true);
+    try {
+      const sessionData = localStorage.getItem('session_data');
+      if (!sessionData) {
+        throw new Error('No session data found');
+      }
+
+      const session = JSON.parse(sessionData);
+      const response = await fetch(`${API_URL}/complete-shoprenter-install`, {
+        method: 'POST',
+        headers: {
+          'Authorization': `Bearer ${session.session.access_token}`,
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({
+          installation_id: pendingInstall.installation_id
+        })
+      });
+
+      const data = await response.json();
+
+      if (!response.ok) {
+        throw new Error(data.error || 'Failed to complete installation');
+      }
+
+      toast({
+        title: t('integrations.oauth.shopConnected', 'Shop Connected'),
+        description: t('integrations.oauth.shopConnectedDescription', { shopName: pendingInstall.shopname }),
+      });
+
+      // Redirect to webshops page
+      navigate('/webshops?sr_connected=true&store=' + encodeURIComponent(pendingInstall.shopname));
+
+    } catch (err) {
+      console.error('Error completing installation:', err);
+      const errorMessage = err instanceof Error ? err.message : 'Failed to complete installation';
+      setError(errorMessage);
+      toast({
+        title: t('integrations.oauth.connectionFailed', 'Connection Failed'),
+        description: errorMessage,
+        variant: "destructive",
+      });
+    } finally {
+      setCompleting(false);
+      setShowAssignDialog(false);
+    }
+  };
+
+  // Handle login redirect
+  const handleLogin = () => {
+    // Store pending installation in sessionStorage to retrieve after login
+    if (pendingInstall) {
+      sessionStorage.setItem('pending_install', JSON.stringify(pendingInstall));
+    }
+    navigate('/login?redirect=/integrations&' + searchParams.toString());
+  };
+
+  // Handle signup redirect
+  const handleSignup = () => {
+    // Store pending installation in sessionStorage to retrieve after signup
+    if (pendingInstall) {
+      sessionStorage.setItem('pending_install', JSON.stringify(pendingInstall));
+    }
+    navigate('/signup?redirect=/integrations&' + searchParams.toString());
+  };
+
+  // Handle creating new account for this shop
+  const handleCreateNewAccount = () => {
+    if (pendingInstall) {
+      sessionStorage.setItem('pending_install', JSON.stringify(pendingInstall));
+    }
+    // Log out current user and redirect to signup
+    localStorage.removeItem('session_data');
+    navigate('/signup?redirect=/integrations&' + searchParams.toString());
+  };
+
+  // Show error state
+  if (error) {
+    return (
+      <div className="min-h-screen flex items-center justify-center bg-slate-900 p-4">
+        <Card className="bg-slate-800 border-slate-700 max-w-md w-full">
+          <CardHeader className="text-center">
+            <AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
+            <CardTitle className="text-white text-2xl">
+              {t('integrations.oauth.connectionFailed', 'Connection Failed')}
+            </CardTitle>
+            <CardDescription className="text-slate-400">
+              {error}
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <Button
+              className="w-full bg-cyan-500 hover:bg-cyan-600 text-white"
+              onClick={() => navigate('/webshops')}
+            >
+              {t('integrations.oauth.backToIntegrations', 'Back to Integrations')}
+            </Button>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  // Show loading state
+  if (authLoading || (!pendingInstall && !error)) {
+    return (
+      <div className="min-h-screen flex items-center justify-center bg-slate-900">
+        <div className="text-center">
+          <Loader2 className="w-12 h-12 text-cyan-500 animate-spin mx-auto mb-4" />
+          <p className="text-slate-400">
+            {t('integrations.oauth.processing', 'Processing integration...')}
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  // Platform info
+  const platformInfo = {
+    shoprenter: {
+      name: 'ShopRenter',
+      color: 'text-blue-500',
+      bgColor: 'bg-blue-500'
+    },
+    shopify: {
+      name: 'Shopify',
+      color: 'text-green-500',
+      bgColor: 'bg-green-500'
+    },
+    woocommerce: {
+      name: 'WooCommerce',
+      color: 'text-purple-500',
+      bgColor: 'bg-purple-500'
+    }
+  };
+
+  const platform = pendingInstall ? platformInfo[pendingInstall.platform] : null;
+
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-slate-900 p-4">
+      {/* Auth Dialog for non-authenticated users */}
+      <Dialog open={showAuthDialog} onOpenChange={setShowAuthDialog}>
+        <DialogContent className="bg-slate-800 border-slate-700 max-w-md">
+          <DialogHeader>
+            <div className="flex items-center justify-center mb-4">
+              <Store className={`w-12 h-12 ${platform?.color || 'text-cyan-500'}`} />
+            </div>
+            <DialogTitle className="text-white text-center text-xl">
+              {t('integrations.oauth.connectShop', 'Connect Your Shop')}
+            </DialogTitle>
+            <DialogDescription className="text-slate-400 text-center">
+              {t('integrations.oauth.authRequired', 'Sign in or create an account to connect')}
+              <span className="font-semibold text-white"> {pendingInstall?.shopname}</span>
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-3 mt-4">
+            <Button
+              className="w-full bg-cyan-500 hover:bg-cyan-600 text-white"
+              onClick={handleLogin}
+            >
+              <LogIn className="w-4 h-4 mr-2" />
+              {t('integrations.oauth.signIn', 'Sign in to your account')}
+            </Button>
+
+            <Button
+              variant="outline"
+              className="w-full border-slate-600 text-white hover:bg-slate-700"
+              onClick={handleSignup}
+            >
+              <UserPlus className="w-4 h-4 mr-2" />
+              {t('integrations.oauth.createAccount', 'Create a new account')}
+            </Button>
+          </div>
+
+          <DialogFooter className="mt-4">
+            <Button
+              variant="ghost"
+              className="w-full text-slate-400 hover:text-white"
+              onClick={() => navigate('/')}
+            >
+              {t('common.cancel', 'Cancel')}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Assign Dialog for authenticated users */}
+      <Dialog open={showAssignDialog} onOpenChange={setShowAssignDialog}>
+        <DialogContent className="bg-slate-800 border-slate-700 max-w-md">
+          <DialogHeader>
+            <div className="flex items-center justify-center mb-4">
+              <CheckCircle className={`w-12 h-12 ${platform?.color || 'text-cyan-500'}`} />
+            </div>
+            <DialogTitle className="text-white text-center text-xl">
+              {t('integrations.oauth.readyToConnect', 'Ready to Connect')}
+            </DialogTitle>
+            <DialogDescription className="text-slate-400 text-center">
+              {t('integrations.oauth.assignShop', 'Connect')}
+              <span className="font-semibold text-white"> {pendingInstall?.shopname}</span>
+              {t('integrations.oauth.toAccount', ' to your account?')}
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-3 mt-4">
+            <Button
+              className="w-full bg-cyan-500 hover:bg-cyan-600 text-white"
+              onClick={completeInstallation}
+              disabled={completing}
+            >
+              {completing ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  {t('integrations.oauth.connecting', 'Connecting...')}
+                </>
+              ) : (
+                <>
+                  <ArrowRight className="w-4 h-4 mr-2" />
+                  {t('integrations.oauth.connectToMyAccount', 'Connect to my account')}
+                </>
+              )}
+            </Button>
+
+            <Button
+              variant="outline"
+              className="w-full border-slate-600 text-white hover:bg-slate-700"
+              onClick={handleCreateNewAccount}
+              disabled={completing}
+            >
+              <UserPlus className="w-4 h-4 mr-2" />
+              {t('integrations.oauth.createNewAccount', 'Create a new account instead')}
+            </Button>
+          </div>
+
+          <DialogFooter className="mt-4">
+            <Button
+              variant="ghost"
+              className="w-full text-slate-400 hover:text-white"
+              onClick={() => {
+                setShowAssignDialog(false);
+                navigate('/webshops');
+              }}
+              disabled={completing}
+            >
+              {t('common.cancel', 'Cancel')}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Fallback content when no dialog is shown */}
+      {!showAuthDialog && !showAssignDialog && pendingInstall && (
+        <Card className="bg-slate-800 border-slate-700 max-w-md w-full">
+          <CardHeader className="text-center">
+            <Store className={`w-16 h-16 ${platform?.color || 'text-cyan-500'} mx-auto mb-4`} />
+            <CardTitle className="text-white text-2xl">
+              {t('integrations.oauth.connectingShop', 'Connecting Shop')}
+            </CardTitle>
+            <CardDescription className="text-slate-400">
+              {pendingInstall.shopname}
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="flex justify-center">
+            <Loader2 className="w-8 h-8 text-cyan-500 animate-spin" />
+          </CardContent>
+        </Card>
+      )}
+    </div>
+  );
+}

+ 235 - 0
supabase/functions/complete-shoprenter-install/index.ts

@@ -0,0 +1,235 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+import { wrapHandler } from '../_shared/error-handler.ts'
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+serve(wrapHandler('complete-shoprenter-install', async (req) => {
+  if (req.method === 'OPTIONS') {
+    return new Response('ok', { headers: corsHeaders })
+  }
+
+  try {
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+    const supabase = createClient(supabaseUrl, supabaseServiceKey)
+
+    // Get auth token from header
+    const authHeader = req.headers.get('Authorization')
+    if (!authHeader) {
+      return new Response(
+        JSON.stringify({ error: 'Authorization header required' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Verify the user
+    const token = authHeader.replace('Bearer ', '')
+    const { data: { user }, error: authError } = await supabase.auth.getUser(token)
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({ error: 'Invalid or expired token' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Parse request body
+    const body = await req.json()
+    const { installation_id } = body
+
+    if (!installation_id) {
+      return new Response(
+        JSON.stringify({ error: 'installation_id is required' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    console.log(`[ShopRenter] Completing installation ${installation_id} for user ${user.id}`)
+
+    // Fetch pending installation
+    const { data: pendingInstall, error: fetchError } = await supabase
+      .from('pending_shoprenter_installs')
+      .select('*')
+      .eq('installation_id', installation_id)
+      .single()
+
+    if (fetchError || !pendingInstall) {
+      console.error('[ShopRenter] Pending installation not found:', fetchError)
+      return new Response(
+        JSON.stringify({ error: 'Installation not found or expired' }),
+        { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Check if installation has expired
+    if (new Date(pendingInstall.expires_at) < new Date()) {
+      console.error('[ShopRenter] Installation has expired')
+      // Clean up expired installation
+      await supabase
+        .from('pending_shoprenter_installs')
+        .delete()
+        .eq('id', pendingInstall.id)
+
+      return new Response(
+        JSON.stringify({ error: 'Installation has expired. Please try again.' }),
+        { status: 410, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Check if store already exists for this user with same shopname
+    const { data: existingStore } = await supabase
+      .from('stores')
+      .select('id')
+      .eq('user_id', user.id)
+      .eq('platform_name', 'shoprenter')
+      .eq('store_name', pendingInstall.shopname)
+      .single()
+
+    if (existingStore) {
+      // Update existing store with new tokens
+      const { error: updateError } = await supabase
+        .from('stores')
+        .update({
+          access_token: pendingInstall.access_token,
+          refresh_token: pendingInstall.refresh_token,
+          token_expires_at: new Date(Date.now() + (pendingInstall.expires_in || 3600) * 1000).toISOString(),
+          scopes: pendingInstall.scopes,
+          is_active: true,
+          updated_at: new Date().toISOString(),
+          phone_number_id: pendingInstall.phone_number_id || existingStore.phone_number_id
+        })
+        .eq('id', existingStore.id)
+
+      if (updateError) {
+        console.error('[ShopRenter] Error updating existing store:', updateError)
+        return new Response(
+          JSON.stringify({ error: 'Failed to update store' }),
+          { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Clean up pending installation
+      await supabase
+        .from('pending_shoprenter_installs')
+        .delete()
+        .eq('id', pendingInstall.id)
+
+      console.log(`[ShopRenter] Updated existing store ${existingStore.id} for ${pendingInstall.shopname}`)
+
+      return new Response(
+        JSON.stringify({
+          success: true,
+          store_id: existingStore.id,
+          message: 'Store reconnected successfully'
+        }),
+        { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Get ShopRenter client credentials for alt_data
+    const shoprenterClientId = Deno.env.get('SHOPRENTER_APP_CLIENT_ID') || Deno.env.get('SHOPRENTER_CLIENT_ID')
+    const shoprenterClientSecret = Deno.env.get('SHOPRENTER_APP_CLIENT_SECRET') || Deno.env.get('SHOPRENTER_CLIENT_SECRET')
+
+    // Create new store
+    const { data: newStore, error: createError } = await supabase
+      .from('stores')
+      .insert({
+        user_id: user.id,
+        platform_name: 'shoprenter',
+        store_name: pendingInstall.shopname,
+        store_url: `https://${pendingInstall.shopname}.myshoprenter.hu`,
+        access_token: pendingInstall.access_token,
+        refresh_token: pendingInstall.refresh_token,
+        token_expires_at: new Date(Date.now() + (pendingInstall.expires_in || 3600) * 1000).toISOString(),
+        scopes: pendingInstall.scopes,
+        is_active: true,
+        phone_number_id: pendingInstall.phone_number_id,
+        alt_data: {
+          client_id: shoprenterClientId,
+          client_secret: shoprenterClientSecret,
+          connectedAt: new Date().toISOString()
+        }
+      })
+      .select()
+      .single()
+
+    if (createError || !newStore) {
+      console.error('[ShopRenter] Error creating store:', createError)
+      return new Response(
+        JSON.stringify({ error: 'Failed to create store' }),
+        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Create default sync configuration
+    const { error: syncConfigError } = await supabase
+      .from('store_sync_config')
+      .insert({
+        store_id: newStore.id,
+        enabled: true,
+        sync_frequency: 'hourly',
+        products_access_policy: 'sync',
+        customers_access_policy: 'api_only',
+        orders_access_policy: 'api_only'
+      })
+
+    if (syncConfigError) {
+      console.error('[ShopRenter] Error creating sync config:', syncConfigError)
+      // Non-fatal error, continue
+    }
+
+    // Update phone number assignment if provided
+    if (pendingInstall.phone_number_id) {
+      const { error: phoneError } = await supabase
+        .from('phone_numbers')
+        .update({
+          assigned_to_store_id: newStore.id,
+          assigned_to_user_id: user.id,
+          assigned_at: new Date().toISOString(),
+          is_available: false
+        })
+        .eq('id', pendingInstall.phone_number_id)
+
+      if (phoneError) {
+        console.error('[ShopRenter] Error updating phone number:', phoneError)
+        // Non-fatal error, continue
+      }
+    }
+
+    // Clean up pending installation
+    await supabase
+      .from('pending_shoprenter_installs')
+      .delete()
+      .eq('id', pendingInstall.id)
+
+    // Clean up any related oauth_states
+    await supabase
+      .from('oauth_states')
+      .delete()
+      .eq('platform', 'shoprenter')
+      .eq('shopname', pendingInstall.shopname)
+      .eq('user_id', user.id)
+
+    console.log(`[ShopRenter] Created new store ${newStore.id} for ${pendingInstall.shopname}`)
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        store_id: newStore.id,
+        message: 'Store connected successfully'
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    )
+
+  } catch (error) {
+    console.error('[ShopRenter] Complete installation error:', error)
+    return new Response(
+      JSON.stringify({ error: 'Failed to complete installation' }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    )
+  }
+}))

+ 225 - 26
supabase/functions/oauth-shoprenter-callback/index.ts

@@ -10,6 +10,180 @@ function bufferToHex(buffer: ArrayBuffer): string {
     .join("");
 }
 
+/**
+ * Makes an HTTP/1.0 request using raw TCP/TLS connection
+ * This is necessary because ShopRenter API requires HTTP/1.0
+ */
+async function makeHttp10Request(
+  hostname: string,
+  path: string,
+  method: string,
+  headers: Record<string, string>,
+  body?: string
+): Promise<{ status: number; statusText: string; headers: Record<string, string>; body: string }> {
+  const conn = await Deno.connectTls({
+    hostname: hostname,
+    port: 443,
+  })
+
+  try {
+    const requestLines: string[] = [
+      `${method} ${path} HTTP/1.0`,
+      `Host: ${hostname}`,
+    ]
+
+    for (const [key, value] of Object.entries(headers)) {
+      requestLines.push(`${key}: ${value}`)
+    }
+
+    requestLines.push('Connection: close')
+    requestLines.push('')
+    requestLines.push('')
+
+    let request = requestLines.join('\r\n')
+
+    if (body) {
+      request = request + body
+    }
+
+    const encoder = new TextEncoder()
+    await conn.write(encoder.encode(request))
+
+    const chunks: Uint8Array[] = []
+    const buffer = new Uint8Array(64 * 1024)
+    let totalBytes = 0
+
+    while (true) {
+      try {
+        const n = await conn.read(buffer)
+        if (n === null) break
+
+        const chunk = new Uint8Array(n)
+        chunk.set(buffer.subarray(0, n))
+        chunks.push(chunk)
+        totalBytes += n
+      } catch (e) {
+        if (e instanceof Deno.errors.UnexpectedEof ||
+            (e instanceof Error && e.name === 'UnexpectedEof')) {
+          break
+        }
+        throw e
+      }
+    }
+
+    const responseData = new Uint8Array(totalBytes)
+    let offset = 0
+    for (const chunk of chunks) {
+      responseData.set(chunk, offset)
+      offset += chunk.length
+    }
+
+    const decoder = new TextDecoder()
+
+    let headerEndIndex = -1
+    let headerSeparatorLength = 0
+
+    for (let i = 0; i < responseData.length - 3; i++) {
+      if (responseData[i] === 13 && responseData[i + 1] === 10 &&
+          responseData[i + 2] === 13 && responseData[i + 3] === 10) {
+        headerEndIndex = i
+        headerSeparatorLength = 4
+        break
+      }
+    }
+
+    if (headerEndIndex === -1) {
+      for (let i = 0; i < responseData.length - 1; i++) {
+        if (responseData[i] === 10 && responseData[i + 1] === 10) {
+          headerEndIndex = i
+          headerSeparatorLength = 2
+          break
+        }
+      }
+    }
+
+    if (headerEndIndex === -1) {
+      throw new Error('Invalid HTTP response: no header/body separator found')
+    }
+
+    const headerBytes = responseData.subarray(0, headerEndIndex)
+    const headerSection = decoder.decode(headerBytes)
+    const bodyBytes = responseData.subarray(headerEndIndex + headerSeparatorLength)
+
+    const lines = headerSection.split(/\r?\n/)
+    const statusLine = lines[0]
+    const statusMatch = statusLine.match(/HTTP\/1\.[01]\s+(\d+)\s+(.*)/)
+
+    if (!statusMatch) {
+      throw new Error(`Invalid HTTP status line: ${statusLine}`)
+    }
+
+    const status = parseInt(statusMatch[1])
+    const statusText = statusMatch[2]
+
+    const responseHeaders: Record<string, string> = {}
+    for (let i = 1; i < lines.length; i++) {
+      const colonIndex = lines[i].indexOf(':')
+      if (colonIndex > 0) {
+        const key = lines[i].substring(0, colonIndex).trim()
+        const value = lines[i].substring(colonIndex + 1).trim()
+        responseHeaders[key] = value
+      }
+    }
+
+    const bodyText = decoder.decode(bodyBytes)
+
+    return {
+      status,
+      statusText,
+      headers: responseHeaders,
+      body: bodyText,
+    }
+  } finally {
+    try {
+      conn.close()
+    } catch (e) {
+      // Gracefully handle connection closure errors
+    }
+  }
+}
+
+// Exchange code for access token using client_credentials grant
+async function getTokenWithClientCredentials(shopname: string, clientId: string, clientSecret: string) {
+  const hostname = 'oauth.app.shoprenter.net'
+  const path = `/${shopname}/app/token`
+
+  console.log(`[ShopRenter] Requesting token for ${shopname} using client_credentials`)
+
+  const requestBody = JSON.stringify({
+    grant_type: 'client_credentials',
+    client_id: clientId,
+    client_secret: clientSecret
+  })
+
+  const requestHeaders: Record<string, string> = {
+    'Content-Type': 'application/json',
+    'Accept': 'application/json',
+    'Content-Length': requestBody.length.toString()
+  }
+
+  try {
+    const response = await makeHttp10Request(hostname, path, 'POST', requestHeaders, requestBody)
+
+    if (response.status !== 200) {
+      console.error('[ShopRenter] Token request error:', response.body)
+      throw new Error(`Failed to get access token: ${response.status} ${response.body}`)
+    }
+
+    const data = JSON.parse(response.body)
+    console.log(`[ShopRenter] Access token obtained for ${shopname}`)
+    return data
+  } catch (error) {
+    console.error('[ShopRenter] Token request error:', error)
+    throw new Error('Failed to get access token via client_credentials')
+  }
+}
+
 // Calculate HMAC-SHA256 using Deno's native crypto.subtle API
 async function calculateHmacSha256(secret: string, message: string): Promise<string> {
   const encoder = new TextEncoder();
@@ -211,48 +385,73 @@ serve(wrapHandler('oauth-shoprenter-callback', async (req) => {
       )
     }
 
-    // Per ShopRenter documentation:
-    // After validating HMAC, we must redirect to app_url
-    // The token exchange happens later when ShopRenter calls our EntryPoint
-    // See: https://doc.shoprenter.hu/development/app-development/01_getting_started.html
+    // After validating HMAC, exchange code for tokens and store pending installation
+    const supabase = createClient(supabaseUrl, supabaseServiceKey)
 
-    if (!app_url) {
-      console.error('[ShopRenter] No app_url provided in callback')
-      return new Response(
-        JSON.stringify({ error: 'Missing app_url parameter' }),
-        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
-      )
+    // Exchange code for access token using client_credentials grant
+    // ShopRenter uses client_credentials flow after app installation
+    let tokenData
+    try {
+      tokenData = await getTokenWithClientCredentials(shopname, shoprenterClientId, shoprenterClientSecret)
+      console.log(`[ShopRenter] Token exchange successful for ${shopname}`)
+    } catch (tokenError) {
+      console.error('[ShopRenter] Token exchange failed:', tokenError)
+      const errorRedirectUrl = `${frontendUrl}/integrations?error=token_exchange_failed&shopname=${encodeURIComponent(shopname)}`
+      return new Response(null, {
+        status: 302,
+        headers: {
+          ...corsHeaders,
+          'Location': errorRedirectUrl
+        }
+      })
     }
 
-    // Store the shopname and code in oauth_nonces for later use during EntryPoint
-    const supabase = createClient(supabaseUrl, supabaseServiceKey)
+    // Generate a unique installation ID
+    const installationId = crypto.randomUUID()
+
+    // Look up phone_number_id from oauth_states if available
+    const { data: oauthState } = await supabase
+      .from('oauth_states')
+      .select('phone_number_id')
+      .eq('platform', 'shoprenter')
+      .eq('shopname', shopname)
+      .order('created_at', { ascending: false })
+      .limit(1)
+      .single()
+
+    const phoneNumberId = oauthState?.phone_number_id || null
 
-    // Generate a unique nonce to pass to ShopRenter
-    const nonce = crypto.randomUUID()
+    // Calculate token expiration
+    const expiresIn = tokenData.expires_in || 3600 // Default to 1 hour
+    const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
 
-    const { error: nonceError } = await supabase
-      .from('oauth_nonces')
+    // Store pending installation with tokens
+    const { error: insertError } = await supabase
+      .from('pending_shoprenter_installs')
       .insert({
-        nonce,
-        platform: 'shoprenter',
+        installation_id: installationId,
         shopname,
-        app_url,
-        expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString() // 15 minutes
+        access_token: tokenData.access_token,
+        refresh_token: tokenData.refresh_token || null,
+        token_type: tokenData.token_type || 'Bearer',
+        expires_in: expiresIn,
+        scopes: tokenData.scope ? tokenData.scope.split(' ') : null,
+        expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(), // 15 minutes to complete installation
+        phone_number_id: phoneNumberId
       })
 
-    if (nonceError) {
-      console.error('[ShopRenter] Error storing nonce:', nonceError)
+    if (insertError) {
+      console.error('[ShopRenter] Error storing pending installation:', insertError)
       return new Response(
         JSON.stringify({ error: 'Failed to process installation' }),
         { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
       )
     }
 
-    // Redirect to app_url as per ShopRenter documentation
-    // We can add our nonce as a query parameter for tracking
-    const redirectUrl = `${app_url}?sr_nonce=${nonce}`
+    // Redirect to frontend integrations page with installation ID
+    const redirectUrl = `${frontendUrl}/integrations?sr_install=${installationId}&shopname=${encodeURIComponent(shopname)}`
 
-    console.log(`[ShopRenter] HMAC validated for ${shopname}, redirecting to app_url: ${redirectUrl}`)
+    console.log(`[ShopRenter] Installation pending for ${shopname}, redirecting to: ${redirectUrl}`)
 
     return new Response(null, {
       status: 302,