Просмотр исходного кода

feat: add cascading country/city/phone selection for store integration #97

- Updated API endpoint to support group_by=countries and group_by=cities
- API now filters by country and city parameters
- IntegrationsRedirect now shows cascading selectors:
  1. Country selector (auto-selects user's country)
  2. City selector (shown after country selection)
  3. Phone number selector (shown after city selection)
- Ensures regulatory compliance for phone number selection

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

Co-Authored-By: Claude <noreply@anthropic.com>
Claude 5 месяцев назад
Родитель
Сommit
8d6d13ce06
2 измененных файлов с 318 добавлено и 43 удалено
  1. 238 41
      shopcall.ai-main/src/pages/IntegrationsRedirect.tsx
  2. 80 2
      supabase/functions/api/index.ts

+ 238 - 41
shopcall.ai-main/src/pages/IntegrationsRedirect.tsx

@@ -21,11 +21,18 @@ interface PhoneNumber {
   id: string;
   id: string;
   phone_number: string;
   phone_number: string;
   country_code: string;
   country_code: string;
-  monthly_price: number;
+  country_name: string;
+  location: string;
+  price: number;
   currency: string;
   currency: string;
   is_available: boolean;
   is_available: boolean;
 }
 }
 
 
+interface Country {
+  country_code: string;
+  country_name: string;
+}
+
 export default function IntegrationsRedirect() {
 export default function IntegrationsRedirect() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const navigate = useNavigate();
   const navigate = useNavigate();
@@ -41,10 +48,17 @@ export default function IntegrationsRedirect() {
   const [checkingAuth, setCheckingAuth] = useState(true);
   const [checkingAuth, setCheckingAuth] = useState(true);
   const [isAuthenticated, setIsAuthenticated] = useState(false);
   const [isAuthenticated, setIsAuthenticated] = useState(false);
 
 
-  // Phone number selection state
+  // Phone number selection state with cascading selectors
+  const [countries, setCountries] = useState<Country[]>([]);
+  const [cities, setCities] = useState<string[]>([]);
   const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumber[]>([]);
   const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumber[]>([]);
+  const [selectedCountry, setSelectedCountry] = useState<string>('');
+  const [selectedCity, setSelectedCity] = useState<string>('');
   const [selectedPhoneNumber, setSelectedPhoneNumber] = useState<string>('');
   const [selectedPhoneNumber, setSelectedPhoneNumber] = useState<string>('');
+  const [loadingCountries, setLoadingCountries] = useState(false);
+  const [loadingCities, setLoadingCities] = useState(false);
   const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false);
   const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false);
+  const [userCountry, setUserCountry] = useState<string | null>(null);
 
 
   // Check authentication status on mount
   // Check authentication status on mount
   useEffect(() => {
   useEffect(() => {
@@ -169,19 +183,75 @@ export default function IntegrationsRedirect() {
     validateAndSetup();
     validateAndSetup();
   }, [searchParams, t]);
   }, [searchParams, t]);
 
 
-  // Fetch available phone numbers when user is authenticated
+  // Fetch available countries when user is authenticated
   useEffect(() => {
   useEffect(() => {
-    const fetchPhoneNumbers = async () => {
+    const fetchCountries = async () => {
       if (!isAuthenticated || !pendingInstall) return;
       if (!isAuthenticated || !pendingInstall) return;
 
 
-      setLoadingPhoneNumbers(true);
+      setLoadingCountries(true);
+      try {
+        const sessionData = localStorage.getItem('session_data');
+        if (!sessionData) return;
+
+        const session = JSON.parse(sessionData);
+        const response = await fetch(`${API_URL}/api/phone-numbers?group_by=countries`, {
+          method: 'GET',
+          headers: {
+            'Authorization': `Bearer ${session.session.access_token}`,
+            'Content-Type': 'application/json'
+          }
+        });
+
+        if (response.ok) {
+          const data = await response.json();
+          setCountries(data.countries || []);
+          // Auto-select user's country if available
+          if (data.user_country) {
+            setUserCountry(data.user_country);
+            // Check if user's country is in the available countries list
+            const hasUserCountry = (data.countries || []).some(
+              (c: Country) => c.country_code === data.user_country
+            );
+            if (hasUserCountry) {
+              setSelectedCountry(data.user_country);
+            }
+          }
+        } else {
+          console.error('Failed to fetch countries:', response.status);
+        }
+      } catch (err) {
+        console.error('Error fetching countries:', err);
+      } finally {
+        setLoadingCountries(false);
+      }
+    };
+
+    fetchCountries();
+  }, [isAuthenticated, pendingInstall]);
+
+  // Fetch cities when country is selected
+  useEffect(() => {
+    const fetchCities = async () => {
+      if (!selectedCountry) {
+        setCities([]);
+        setSelectedCity('');
+        setPhoneNumbers([]);
+        setSelectedPhoneNumber('');
+        return;
+      }
+
+      setLoadingCities(true);
+      setCities([]);
+      setSelectedCity('');
+      setPhoneNumbers([]);
+      setSelectedPhoneNumber('');
+
       try {
       try {
         const sessionData = localStorage.getItem('session_data');
         const sessionData = localStorage.getItem('session_data');
         if (!sessionData) return;
         if (!sessionData) return;
 
 
         const session = JSON.parse(sessionData);
         const session = JSON.parse(sessionData);
-        // Fetch all available phone numbers (no country filter for store integration)
-        const response = await fetch(`${API_URL}/api/phone-numbers?available=true&all_countries=true`, {
+        const response = await fetch(`${API_URL}/api/phone-numbers?group_by=cities&country=${selectedCountry}`, {
           method: 'GET',
           method: 'GET',
           headers: {
           headers: {
             'Authorization': `Bearer ${session.session.access_token}`,
             'Authorization': `Bearer ${session.session.access_token}`,
@@ -189,6 +259,51 @@ export default function IntegrationsRedirect() {
           }
           }
         });
         });
 
 
+        if (response.ok) {
+          const data = await response.json();
+          setCities(data.cities || []);
+        } else {
+          console.error('Failed to fetch cities:', response.status);
+        }
+      } catch (err) {
+        console.error('Error fetching cities:', err);
+      } finally {
+        setLoadingCities(false);
+      }
+    };
+
+    fetchCities();
+  }, [selectedCountry]);
+
+  // Fetch phone numbers when city is selected
+  useEffect(() => {
+    const fetchPhoneNumbers = async () => {
+      if (!selectedCountry || !selectedCity) {
+        setPhoneNumbers([]);
+        setSelectedPhoneNumber('');
+        return;
+      }
+
+      setLoadingPhoneNumbers(true);
+      setPhoneNumbers([]);
+      setSelectedPhoneNumber('');
+
+      try {
+        const sessionData = localStorage.getItem('session_data');
+        if (!sessionData) return;
+
+        const session = JSON.parse(sessionData);
+        const response = await fetch(
+          `${API_URL}/api/phone-numbers?available=true&country=${selectedCountry}&city=${encodeURIComponent(selectedCity)}`,
+          {
+            method: 'GET',
+            headers: {
+              'Authorization': `Bearer ${session.session.access_token}`,
+              'Content-Type': 'application/json'
+            }
+          }
+        );
+
         if (response.ok) {
         if (response.ok) {
           const data = await response.json();
           const data = await response.json();
           setPhoneNumbers(data.phone_numbers || []);
           setPhoneNumbers(data.phone_numbers || []);
@@ -203,7 +318,7 @@ export default function IntegrationsRedirect() {
     };
     };
 
 
     fetchPhoneNumbers();
     fetchPhoneNumbers();
-  }, [isAuthenticated, pendingInstall]);
+  }, [selectedCountry, selectedCity]);
 
 
   // Handle auth state changes
   // Handle auth state changes
   useEffect(() => {
   useEffect(() => {
@@ -433,61 +548,143 @@ export default function IntegrationsRedirect() {
             </DialogDescription>
             </DialogDescription>
           </DialogHeader>
           </DialogHeader>
 
 
-          {/* Phone Number Selection */}
-          <div className="mt-4 space-y-3">
+          {/* Phone Number Selection - Cascading: Country → City → Phone */}
+          <div className="mt-4 space-y-4">
+            {/* Country Selector */}
             <div className="space-y-2">
             <div className="space-y-2">
               <Label className="text-slate-300 flex items-center gap-2">
               <Label className="text-slate-300 flex items-center gap-2">
-                <Phone className="w-4 h-4" />
-                {t('integrations.oauth.selectPhoneNumber', 'Select Phone Number')}
+                {t('integrations.oauth.selectCountry', 'Country')}
                 <span className="text-red-400">*</span>
                 <span className="text-red-400">*</span>
               </Label>
               </Label>
-              {loadingPhoneNumbers ? (
+              {loadingCountries ? (
                 <div className="flex items-center justify-center py-3">
                 <div className="flex items-center justify-center py-3">
                   <Loader2 className="w-5 h-5 text-cyan-500 animate-spin" />
                   <Loader2 className="w-5 h-5 text-cyan-500 animate-spin" />
                   <span className="ml-2 text-sm text-slate-400">
                   <span className="ml-2 text-sm text-slate-400">
-                    {t('integrations.oauth.loadingPhoneNumbers', 'Loading available phone numbers...')}
+                    {t('integrations.oauth.loadingCountries', 'Loading countries...')}
                   </span>
                   </span>
                 </div>
                 </div>
-              ) : phoneNumbers.length > 0 ? (
-                <>
-                  <Select value={selectedPhoneNumber} onValueChange={setSelectedPhoneNumber}>
-                    <SelectTrigger className={`bg-slate-700 border-slate-600 text-white ${!selectedPhoneNumber ? 'border-red-500/50' : ''}`}>
-                      <SelectValue placeholder={t('integrations.oauth.selectPhonePlaceholder', 'Select a phone number')} />
-                    </SelectTrigger>
-                    <SelectContent className="bg-slate-700 border-slate-600">
-                      {phoneNumbers.map((phone) => (
-                        <SelectItem key={phone.id} value={phone.id} className="text-white hover:bg-slate-600">
-                          <div className="flex items-center justify-between w-full">
-                            <span>{phone.phone_number}</span>
-                            <span className="ml-2 text-sm text-slate-400">
-                              {phone.monthly_price > 0 ? `${phone.monthly_price} ${phone.currency}/mo` : t('common.free', 'Free')}
-                            </span>
-                          </div>
-                        </SelectItem>
-                      ))}
-                    </SelectContent>
-                  </Select>
-                  {!selectedPhoneNumber && (
-                    <p className="text-xs text-red-400">
-                      {t('integrations.oauth.phoneNumberRequired', 'Please select a phone number to continue.')}
-                    </p>
-                  )}
-                </>
+              ) : countries.length > 0 ? (
+                <Select value={selectedCountry} onValueChange={setSelectedCountry}>
+                  <SelectTrigger className={`bg-slate-700 border-slate-600 text-white ${!selectedCountry ? 'border-red-500/50' : ''}`}>
+                    <SelectValue placeholder={t('integrations.oauth.selectCountryPlaceholder', 'Select a country')} />
+                  </SelectTrigger>
+                  <SelectContent className="bg-slate-700 border-slate-600">
+                    {countries.map((country) => (
+                      <SelectItem key={country.country_code} value={country.country_code} className="text-white hover:bg-slate-600">
+                        {country.country_name}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
               ) : (
               ) : (
                 <div className="bg-red-500/10 border border-red-500/30 rounded-md p-3">
                 <div className="bg-red-500/10 border border-red-500/30 rounded-md p-3">
                   <p className="text-sm text-red-400">
                   <p className="text-sm text-red-400">
-                    {t('integrations.oauth.noPhoneNumbersError', 'No phone numbers available. Please add a phone number first before connecting your store.')}
+                    {t('integrations.oauth.noCountriesError', 'No countries with available phone numbers.')}
                   </p>
                   </p>
                 </div>
                 </div>
               )}
               )}
             </div>
             </div>
+
+            {/* City Selector - shown after country selection */}
+            {selectedCountry && (
+              <div className="space-y-2">
+                <Label className="text-slate-300 flex items-center gap-2">
+                  {t('integrations.oauth.selectCity', 'City')}
+                  <span className="text-red-400">*</span>
+                </Label>
+                {loadingCities ? (
+                  <div className="flex items-center justify-center py-3">
+                    <Loader2 className="w-5 h-5 text-cyan-500 animate-spin" />
+                    <span className="ml-2 text-sm text-slate-400">
+                      {t('integrations.oauth.loadingCities', 'Loading cities...')}
+                    </span>
+                  </div>
+                ) : cities.length > 0 ? (
+                  <Select value={selectedCity} onValueChange={setSelectedCity}>
+                    <SelectTrigger className={`bg-slate-700 border-slate-600 text-white ${!selectedCity ? 'border-red-500/50' : ''}`}>
+                      <SelectValue placeholder={t('integrations.oauth.selectCityPlaceholder', 'Select a city')} />
+                    </SelectTrigger>
+                    <SelectContent className="bg-slate-700 border-slate-600">
+                      {cities.map((city) => (
+                        <SelectItem key={city} value={city} className="text-white hover:bg-slate-600">
+                          {city}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                ) : (
+                  <div className="bg-yellow-500/10 border border-yellow-500/30 rounded-md p-3">
+                    <p className="text-sm text-yellow-400">
+                      {t('integrations.oauth.noCitiesError', 'No cities with available phone numbers in this country.')}
+                    </p>
+                  </div>
+                )}
+              </div>
+            )}
+
+            {/* Phone Number Selector - shown after city selection */}
+            {selectedCity && (
+              <div className="space-y-2">
+                <Label className="text-slate-300 flex items-center gap-2">
+                  <Phone className="w-4 h-4" />
+                  {t('integrations.oauth.selectPhoneNumber', 'Phone Number')}
+                  <span className="text-red-400">*</span>
+                </Label>
+                {loadingPhoneNumbers ? (
+                  <div className="flex items-center justify-center py-3">
+                    <Loader2 className="w-5 h-5 text-cyan-500 animate-spin" />
+                    <span className="ml-2 text-sm text-slate-400">
+                      {t('integrations.oauth.loadingPhoneNumbers', 'Loading available phone numbers...')}
+                    </span>
+                  </div>
+                ) : phoneNumbers.length > 0 ? (
+                  <>
+                    <Select value={selectedPhoneNumber} onValueChange={setSelectedPhoneNumber}>
+                      <SelectTrigger className={`bg-slate-700 border-slate-600 text-white ${!selectedPhoneNumber ? 'border-red-500/50' : ''}`}>
+                        <SelectValue placeholder={t('integrations.oauth.selectPhonePlaceholder', 'Select a phone number')} />
+                      </SelectTrigger>
+                      <SelectContent className="bg-slate-700 border-slate-600">
+                        {phoneNumbers.map((phone) => (
+                          <SelectItem key={phone.id} value={phone.id} className="text-white hover:bg-slate-600">
+                            <div className="flex items-center justify-between w-full">
+                              <span>{phone.phone_number}</span>
+                              <span className="ml-2 text-sm text-slate-400">
+                                {phone.price > 0 ? `${phone.price}/mo` : t('common.free', 'Free')}
+                              </span>
+                            </div>
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                    {!selectedPhoneNumber && (
+                      <p className="text-xs text-red-400">
+                        {t('integrations.oauth.phoneNumberRequired', 'Please select a phone number to continue.')}
+                      </p>
+                    )}
+                  </>
+                ) : (
+                  <div className="bg-red-500/10 border border-red-500/30 rounded-md p-3">
+                    <p className="text-sm text-red-400">
+                      {t('integrations.oauth.noPhoneNumbersError', 'No phone numbers available in this city.')}
+                    </p>
+                  </div>
+                )}
+              </div>
+            )}
+
+            {/* Show message when not all selections made */}
+            {!selectedPhoneNumber && countries.length > 0 && (
+              <p className="text-xs text-slate-400">
+                {t('integrations.oauth.selectionRequired', 'Please select a country, city, and phone number to continue.')}
+              </p>
+            )}
           </div>
           </div>
 
 
           <div className="space-y-3 mt-4">
           <div className="space-y-3 mt-4">
             <Button
             <Button
               className="w-full bg-cyan-500 hover:bg-cyan-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
               className="w-full bg-cyan-500 hover:bg-cyan-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
               onClick={completeInstallation}
               onClick={completeInstallation}
-              disabled={completing || loadingPhoneNumbers || !selectedPhoneNumber || phoneNumbers.length === 0}
+              disabled={completing || loadingCountries || loadingCities || loadingPhoneNumbers || !selectedPhoneNumber || countries.length === 0}
             >
             >
               {completing ? (
               {completing ? (
                 <>
                 <>

+ 80 - 2
supabase/functions/api/index.ts

@@ -1757,6 +1757,75 @@ serve(async (req) => {
         // Check query parameters
         // Check query parameters
         const availableOnly = url.searchParams.get('available') === 'true'
         const availableOnly = url.searchParams.get('available') === 'true'
         const allCountries = url.searchParams.get('all_countries') === 'true'
         const allCountries = url.searchParams.get('all_countries') === 'true'
+        const countryFilter = url.searchParams.get('country')
+        const cityFilter = url.searchParams.get('city')
+        const groupBy = url.searchParams.get('group_by') // 'countries' or 'cities'
+
+        // Special endpoint: Get list of countries with available phone numbers
+        if (groupBy === 'countries') {
+          const { data: countries, error: countriesError } = await supabase
+            .from('phone_numbers')
+            .select('country_code, country_name')
+            .eq('is_active', true)
+            .eq('is_available', true)
+            .is('assigned_to_store_id', null)
+            .order('country_name', { ascending: true })
+
+          if (countriesError) {
+            console.error('Error fetching countries:', countriesError)
+            return new Response(
+              JSON.stringify({ error: 'Failed to fetch countries' }),
+              { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+            )
+          }
+
+          // Get unique countries
+          const uniqueCountries = Array.from(
+            new Map((countries || []).map(c => [c.country_code, c])).values()
+          )
+
+          return new Response(
+            JSON.stringify({
+              success: true,
+              countries: uniqueCountries,
+              user_country: profile?.country_code || null
+            }),
+            { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+          )
+        }
+
+        // Special endpoint: Get list of cities for a country
+        if (groupBy === 'cities' && countryFilter) {
+          const { data: cities, error: citiesError } = await supabase
+            .from('phone_numbers')
+            .select('location')
+            .eq('is_active', true)
+            .eq('is_available', true)
+            .eq('country_code', countryFilter)
+            .is('assigned_to_store_id', null)
+            .order('location', { ascending: true })
+
+          if (citiesError) {
+            console.error('Error fetching cities:', citiesError)
+            return new Response(
+              JSON.stringify({ error: 'Failed to fetch cities' }),
+              { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+            )
+          }
+
+          // Get unique cities
+          const uniqueCities = Array.from(
+            new Set((cities || []).map(c => c.location))
+          ).filter(Boolean)
+
+          return new Response(
+            JSON.stringify({
+              success: true,
+              cities: uniqueCities
+            }),
+            { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+          )
+        }
 
 
         // Build query for phone numbers
         // Build query for phone numbers
         let query = supabase
         let query = supabase
@@ -1769,13 +1838,22 @@ serve(async (req) => {
         // Filter by availability if requested
         // Filter by availability if requested
         if (availableOnly) {
         if (availableOnly) {
           query = query.eq('is_available', true)
           query = query.eq('is_available', true)
+          query = query.is('assigned_to_store_id', null)
         }
         }
 
 
-        // Filter by user's country if set (unless all_countries=true)
-        if (profile?.country_code && !allCountries) {
+        // Filter by specific country if provided
+        if (countryFilter) {
+          query = query.eq('country_code', countryFilter)
+        } else if (profile?.country_code && !allCountries) {
+          // Filter by user's country if set (unless all_countries=true)
           query = query.eq('country_code', profile.country_code)
           query = query.eq('country_code', profile.country_code)
         }
         }
 
 
+        // Filter by specific city if provided
+        if (cityFilter) {
+          query = query.eq('location', cityFilter)
+        }
+
         const { data: phoneNumbers, error: phoneError } = await query
         const { data: phoneNumbers, error: phoneError } = await query
 
 
         if (phoneError) {
         if (phoneError) {