mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Dev (#394)
* Make SBP default and first top-up option (#389) * fix(connection): happ cryptolink flow + ui fixes (#385) * fix(connection): respect happ_cryptolink mode - read connect_mode from connection-link endpoint - auto-redirect to happ cryptolink flow when mode is HAPP_CRYPTOLINK - keep installation guide behavior for other modes * docs(readme): fix source build step 2 - use compose service name for build/create commands - copy static files from created compose container - remove compose container via docker compose rm * fix(connection): handle mode and ws path - treat any non-guide connect mode as direct happ redirect - wait for connection-link response before rendering installation guide - build websocket URL from VITE_API_URL (supports /api and absolute URLs) * docs(readme): fix docker cp static path - copy /usr/share/nginx/html contents via html/. - prevent nested /srv/cabinet/html deployment and 404 on root * fix(connection): keep guide, use happ links - restore guide page behavior for /connection (no auto redirect) - use happ cryptolink URL in /connection/qr when happ crypt mode is active - replace subscription page connection URL with happ cryptolink from connection-link API * fix(connection): avoid wrong redirect flow - force happ scheme deeplink in HAPP_CRYPTOLINK mode for guide connect buttons - use cabinet redirect page only inside Telegram Mini App - open deeplink directly in regular browsers * fix(connection): enforce happ cryptolink - prioritize backend crypt deeplink fields in HAPP_CRYPTOLINK mode - fallback to local crypt4/crypt3 generation from subscription URL when backend returns plain happ://sub... - apply same resolution for guide open action, qr page source and subscription page link display/copy * fix(subscription): truncate long connect link - render connection URL in a single line with ellipsis on /subscriptions - preserve full URL in tooltip for easier manual copy - keep copy button behavior unchanged * fix(connection): build cryptolink from happ sub - allow cryptolink fallback generation from happ://sub... URLs in addition to http(s) - prevent plain happ://sub links from leaking into UI in HAPP_CRYPTOLINK mode * fix: allow saving 0% period discount in promo groups Changed condition from percent > 0 to percent >= 0 so that admins can explicitly set 0% discount for a period. Previously 0% entries were silently dropped and not sent to the backend. * fix: always send period_discounts in promo group updates When all period discounts were removed, the frontend sent undefined (field absent from JSON), so the backend never cleared them. Now always sends the field - empty object {} to clear, or populated object to update. --------- Co-authored-by: zavul0nn <34007368+zavul0nn@users.noreply.github.com> Co-authored-by: Dxnil <62987903+D4nilKO@users.noreply.github.com>
This commit is contained in:
@@ -135,7 +135,7 @@ export default function AdminPromoGroupCreate() {
|
||||
periodDiscounts.forEach((pd) => {
|
||||
const days = pd.days === '' ? 0 : pd.days;
|
||||
const percent = pd.percent === '' ? 0 : pd.percent;
|
||||
if (days > 0 && percent > 0) {
|
||||
if (days > 0 && percent >= 0) {
|
||||
periodDiscountsRecord[days] = percent;
|
||||
}
|
||||
});
|
||||
@@ -150,8 +150,7 @@ export default function AdminPromoGroupCreate() {
|
||||
server_discount_percent: serverVal,
|
||||
traffic_discount_percent: trafficVal,
|
||||
device_discount_percent: deviceVal,
|
||||
period_discounts:
|
||||
Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined,
|
||||
period_discounts: periodDiscountsRecord,
|
||||
apply_discounts_to_addons: applyToAddons,
|
||||
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : 0,
|
||||
is_default: isDefault,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { subscriptionApi } from '../api/subscription';
|
||||
import { useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||
import { useHaptic } from '@/platform';
|
||||
import { resolveTemplate, hasTemplates } from '../utils/templateEngine';
|
||||
import { isHappCryptolinkMode, resolveConnectionUrlForUi } from '../utils/connectionLink';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import type { AppConfig, RemnawavePlatformData } from '../types';
|
||||
import InstallationGuide from '../components/connection/InstallationGuide';
|
||||
@@ -32,21 +33,59 @@ export default function Connection() {
|
||||
queryKey: ['appConfig', subId],
|
||||
queryFn: () => subscriptionApi.getAppConfig(subId),
|
||||
});
|
||||
const { data: connectionLink, isLoading: isConnectionLinkLoading } = useQuery({
|
||||
queryKey: ['connectionLink', subId],
|
||||
queryFn: () => subscriptionApi.getConnectionLink(subId),
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const qrConnectionUrl = useMemo(
|
||||
() =>
|
||||
resolveConnectionUrlForUi({
|
||||
mode: connectionLink?.connect_mode,
|
||||
happSchemeLink: connectionLink?.happ_scheme_link,
|
||||
displayLink: connectionLink?.display_link,
|
||||
subscriptionUrl: connectionLink?.subscription_url,
|
||||
happCryptLink: connectionLink?.happ_cryptolink,
|
||||
happCryptoLink: connectionLink?.happ_crypto_link,
|
||||
happLink: connectionLink?.happ_link,
|
||||
fallbackUrl: appConfig?.subscriptionUrl,
|
||||
}),
|
||||
[
|
||||
appConfig?.subscriptionUrl,
|
||||
connectionLink?.connect_mode,
|
||||
connectionLink?.display_link,
|
||||
connectionLink?.happ_cryptolink,
|
||||
connectionLink?.happ_crypto_link,
|
||||
connectionLink?.happ_link,
|
||||
connectionLink?.happ_scheme_link,
|
||||
connectionLink?.subscription_url,
|
||||
],
|
||||
);
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
|
||||
const handleOpenQR = useCallback(() => {
|
||||
if (!qrConnectionUrl) return;
|
||||
navigate('/connection/qr', {
|
||||
replace: !isTelegramWebApp,
|
||||
state: {
|
||||
url: appConfig?.subscriptionUrl,
|
||||
hideLink: appConfig?.hideLink ?? false,
|
||||
url: qrConnectionUrl,
|
||||
hideLink: connectionLink?.hide_link ?? appConfig?.hideLink ?? false,
|
||||
subscriptionId: subId,
|
||||
},
|
||||
});
|
||||
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp, subId]);
|
||||
}, [
|
||||
navigate,
|
||||
qrConnectionUrl,
|
||||
connectionLink?.hide_link,
|
||||
appConfig?.hideLink,
|
||||
isTelegramWebApp,
|
||||
subId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -73,24 +112,30 @@ export default function Connection() {
|
||||
const openDeepLink = useCallback(
|
||||
(deepLink: string) => {
|
||||
let resolved = deepLink;
|
||||
if (hasTemplates(resolved)) {
|
||||
if (isHappCryptolinkMode(connectionLink?.connect_mode) && qrConnectionUrl) {
|
||||
// In HAPP cryptolink mode always open the resolved happ://crypt... URL.
|
||||
resolved = qrConnectionUrl;
|
||||
} else if (hasTemplates(resolved)) {
|
||||
resolved = resolveUrl(resolved);
|
||||
}
|
||||
|
||||
const finalUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`;
|
||||
const isHttpUrl = /^https?:\/\//i.test(resolved);
|
||||
const finalUrlForTelegram = isHttpUrl
|
||||
? resolved
|
||||
: `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`;
|
||||
|
||||
if (isTelegramWebApp) {
|
||||
try {
|
||||
sdkOpenLink(finalUrl, { tryInstantView: false });
|
||||
sdkOpenLink(finalUrlForTelegram, { tryInstantView: false });
|
||||
return;
|
||||
} catch {
|
||||
// SDK not available, fallback
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = finalUrl;
|
||||
// In regular browsers open deeplink directly (without intermediate redirect page).
|
||||
window.location.href = resolved;
|
||||
},
|
||||
[isTelegramWebApp, i18n.language, resolveUrl],
|
||||
[isTelegramWebApp, i18n.language, resolveUrl, connectionLink?.connect_mode, qrConnectionUrl],
|
||||
);
|
||||
|
||||
// Check if any platform has configured apps
|
||||
@@ -101,7 +146,7 @@ export default function Connection() {
|
||||
);
|
||||
}, [appConfig?.platforms]);
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || isConnectionLinkLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-20">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router';
|
||||
@@ -18,6 +18,7 @@ import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||
import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton';
|
||||
import { CopyIcon, CheckIcon } from '../components/icons';
|
||||
import { useHaptic } from '../platform';
|
||||
import { resolveConnectionUrlForUi } from '../utils/connectionLink';
|
||||
import {
|
||||
getErrorMessage,
|
||||
getInsufficientBalanceError,
|
||||
@@ -221,9 +222,41 @@ export default function Subscription() {
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
const { data: connectionLink, isLoading: isConnectionLinkLoading } = useQuery({
|
||||
queryKey: ['connection-link', subscriptionId],
|
||||
queryFn: () => subscriptionApi.getConnectionLink(subscriptionId),
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Extract subscription from response (null if no subscription)
|
||||
const subscription = subscriptionResponse?.subscription ?? null;
|
||||
const displayedConnectionUrl = useMemo(
|
||||
() =>
|
||||
resolveConnectionUrlForUi({
|
||||
mode: connectionLink?.connect_mode,
|
||||
happSchemeLink: connectionLink?.happ_scheme_link,
|
||||
displayLink: connectionLink?.display_link,
|
||||
subscriptionUrl: connectionLink?.subscription_url,
|
||||
happCryptLink: connectionLink?.happ_cryptolink,
|
||||
happCryptoLink: connectionLink?.happ_crypto_link,
|
||||
happLink: connectionLink?.happ_link,
|
||||
fallbackUrl: isConnectionLinkLoading ? null : (subscription?.subscription_url ?? null),
|
||||
}),
|
||||
[
|
||||
connectionLink?.connect_mode,
|
||||
connectionLink?.display_link,
|
||||
connectionLink?.happ_cryptolink,
|
||||
connectionLink?.happ_crypto_link,
|
||||
connectionLink?.happ_link,
|
||||
connectionLink?.happ_scheme_link,
|
||||
connectionLink?.subscription_url,
|
||||
isConnectionLinkLoading,
|
||||
subscription?.subscription_url,
|
||||
],
|
||||
);
|
||||
const shouldHideConnectionLink =
|
||||
subscription?.hide_subscription_link || connectionLink?.hide_link;
|
||||
|
||||
// Traffic zone (theme-aware) — called unconditionally at top level
|
||||
const usedPercent = trafficData?.traffic_used_percent ?? subscription?.traffic_used_percent ?? 0;
|
||||
@@ -453,8 +486,8 @@ export default function Subscription() {
|
||||
}, [subscription, refreshTrafficMutation, subscriptionId]);
|
||||
|
||||
const copyUrl = () => {
|
||||
if (subscription?.subscription_url) {
|
||||
navigator.clipboard.writeText(subscription.subscription_url);
|
||||
if (displayedConnectionUrl) {
|
||||
navigator.clipboard.writeText(displayedConnectionUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
@@ -890,16 +923,17 @@ export default function Subscription() {
|
||||
)}
|
||||
|
||||
{/* ─── Subscription URL ─── */}
|
||||
{subscription.subscription_url && !subscription.hide_subscription_link && (
|
||||
{displayedConnectionUrl && !shouldHideConnectionLink && (
|
||||
<div className="mb-5 flex gap-2">
|
||||
<code
|
||||
className="scrollbar-hide flex-1 overflow-x-auto break-all rounded-[10px] px-3 py-2 font-mono text-[11px] text-dark-50/30"
|
||||
className="block min-w-0 flex-1 truncate whitespace-nowrap rounded-[10px] px-3 py-2 font-mono text-[11px] text-dark-50/30"
|
||||
style={{
|
||||
background: g.codeBg,
|
||||
border: `1px solid ${g.codeBorder}`,
|
||||
}}
|
||||
title={displayedConnectionUrl}
|
||||
>
|
||||
{subscription.subscription_url}
|
||||
{displayedConnectionUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils
|
||||
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||
import { useHaptic, usePlatform } from '@/platform';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
import type { PaymentMethod } from '../types';
|
||||
import type { PaymentMethod, PaymentMethodOption } from '../types';
|
||||
import BentoCard from '../components/ui/BentoCard';
|
||||
import { saveTopUpPendingInfo } from '../utils/topUpStorage';
|
||||
|
||||
@@ -80,6 +80,44 @@ const getMethodIcon = (methodId: string) => {
|
||||
return <CardIcon />;
|
||||
};
|
||||
|
||||
const getPreferredOptionId = (options?: PaymentMethod['options']) => {
|
||||
if (!options || options.length === 0) return null;
|
||||
|
||||
const sbpOption = options.find((option) => {
|
||||
const normalizedId = option.id.toLowerCase();
|
||||
const normalizedName = option.name.toLowerCase();
|
||||
return (
|
||||
normalizedId.includes('sbp') ||
|
||||
normalizedName.includes('сбп') ||
|
||||
normalizedName.includes('sbp')
|
||||
);
|
||||
});
|
||||
|
||||
return sbpOption?.id ?? options[0].id;
|
||||
};
|
||||
|
||||
const sortOptionsWithSbpFirst = (options?: PaymentMethod['options']) => {
|
||||
if (!options || options.length <= 1) return options ?? [];
|
||||
|
||||
const isPreferredOption = (option: PaymentMethodOption) => {
|
||||
const normalizedId = option.id.toLowerCase();
|
||||
const normalizedName = option.name.toLowerCase();
|
||||
return (
|
||||
normalizedId.includes('sbp') ||
|
||||
normalizedName.includes('сбп') ||
|
||||
normalizedName.includes('sbp')
|
||||
);
|
||||
};
|
||||
|
||||
return [...options].sort((left, right) => {
|
||||
const leftIsPreferred = isPreferredOption(left);
|
||||
const rightIsPreferred = isPreferredOption(right);
|
||||
|
||||
if (leftIsPreferred === rightIsPreferred) return 0;
|
||||
return leftIsPreferred ? -1 : 1;
|
||||
});
|
||||
};
|
||||
|
||||
export default function TopUpAmount() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -135,7 +173,7 @@ export default function TopUpAmount() {
|
||||
const [amount, setAmount] = useState(getInitialAmount);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||
method?.options && method.options.length > 0 ? method.options[0].id : null,
|
||||
getPreferredOptionId(method?.options),
|
||||
);
|
||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -154,6 +192,20 @@ export default function TopUpAmount() {
|
||||
}
|
||||
}, [cachedMethods, method, navigate, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!method?.options || method.options.length === 0) {
|
||||
if (selectedOption !== null) {
|
||||
setSelectedOption(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const optionExists = method.options.some((option) => option.id === selectedOption);
|
||||
if (!optionExists) {
|
||||
setSelectedOption(getPreferredOptionId(method.options));
|
||||
}
|
||||
}, [method?.id, method?.options, selectedOption]);
|
||||
|
||||
const starsPaymentMutation = useMutation({
|
||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||
onSuccess: async (data) => {
|
||||
@@ -248,6 +300,7 @@ export default function TopUpAmount() {
|
||||
}
|
||||
|
||||
const hasOptions = method.options && method.options.length > 0;
|
||||
const orderedOptions = sortOptionsWithSbpFirst(method.options);
|
||||
const minRubles = method.min_amount_kopeks / 100;
|
||||
const maxRubles = method.max_amount_kopeks / 100;
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
@@ -344,11 +397,11 @@ export default function TopUpAmount() {
|
||||
</motion.div>
|
||||
|
||||
{/* Payment options (if any) */}
|
||||
{hasOptions && method.options && (
|
||||
{hasOptions && orderedOptions.length > 0 && (
|
||||
<motion.div variants={staggerItem} className="space-y-2">
|
||||
<label className="text-sm font-medium text-dark-400">{t('balance.paymentMethod')}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{method.options.map((opt) => (
|
||||
{orderedOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user