Merge remote-tracking branch 'upstream/main'

This commit is contained in:
EUForest
2026-02-13 23:11:47 +08:00
54 changed files with 309 additions and 39 deletions
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OAuth Redirect</title>
<meta http-equiv="refresh" content="0; url=/#/auth">
<script>
"use strict";
// Providers redirect to a path without URL fragments (#). Our app uses hash routing.
// Bridge /oauth/<provider>[/]?... -> /#/oauth/<provider>?...
(() => {
try {
// Normalize trailing slash so /oauth/google/ and /oauth/google both map to the same route.
let path = window.location.pathname || "/";
path = path.replace(/\/$/, "");
const search = window.location.search || "";
const target = `/#${path}${search}`;
window.location.replace(target);
} catch (_e) {
window.location.replace("/#/auth");
}
})();
</script>
</head>
<body>Redirecting…</body>
</html>
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OAuth Redirect</title>
<meta http-equiv="refresh" content="0; url=/#/auth">
<script>
"use strict";
// Providers redirect to a path without URL fragments (#). Our app uses hash routing.
// Bridge /oauth/<provider>[/]?... -> /#/oauth/<provider>?...
(() => {
try {
let path = window.location.pathname || "/";
path = path.replace(/\/$/, "");
const search = window.location.search || "";
const target = `/#${path}${search}`;
window.location.replace(target);
} catch (_e) {
window.location.replace("/#/auth");
}
})();
</script>
</head>
<body>Redirecting…</body>
</html>
@@ -35,7 +35,10 @@ export function OAuthMethods() {
onClick={async () => {
const { data } = await oAuthLogin({
method,
redirect: `${window.location.origin}/oauth/${method}`,
// OAuth providers disallow URL fragments (#) in redirect URIs.
// Use a real path (with trailing slash so static hosting can serve /oauth/<provider>/index.html)
// which then bridges into our hash-router at /#/oauth/<provider>.
redirect: `${window.location.origin}/oauth/${method}/`,
});
if (data.data?.redirect) {
window.location.href = data.data?.redirect;
@@ -9,7 +9,7 @@ import {
import { cn } from "@workspace/ui/lib/utils";
import { getAvailablePaymentMethods } from "@workspace/ui/services/user/portal";
import type React from "react";
import { memo } from "react";
import { memo, useEffect } from "react";
import { useTranslation } from "react-i18next";
interface PaymentMethodsProps {
@@ -30,12 +30,21 @@ const PaymentMethods: React.FC<PaymentMethodsProps> = ({
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
const list = data.data?.list || [];
const methods = balance ? list : list.filter((item) => item.id !== -1);
const defaultMethod = methods.find((item) => item.id)?.id;
if (defaultMethod) onChange(defaultMethod);
return methods;
return balance ? list : list.filter((item) => item.id !== -1);
},
});
// Only set a default when the current value is not a valid option.
// This avoids resetting the user's selection on refetch (common on mobile).
// Prefer non-balance methods when possible.
useEffect(() => {
if (!data || data.length === 0) return;
const valid = data.some((m) => String(m.id) === String(value));
if (valid) return;
const preferred = data.find((m) => m.id !== -1)?.id ?? data[0]!.id;
onChange(preferred);
}, [data, onChange, value]);
return (
<>
<div className="font-semibold">
@@ -44,7 +53,6 @@ const PaymentMethods: React.FC<PaymentMethodsProps> = ({
<RadioGroup
className="grid grid-cols-2 gap-2 md:grid-cols-5"
onValueChange={(val) => {
console.log(val);
onChange(Number(val));
}}
value={String(value)}
+8 -2
View File
@@ -11,12 +11,14 @@ import Empty from "@workspace/ui/composed/empty";
import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown";
import { queryAnnouncement } from "@workspace/ui/services/user/announcement";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
export default function Announcement({ type }: { type: "popup" | "pinned" }) {
const { t } = useTranslation("dashboard");
const { user } = useGlobalStore();
const [open, setOpen] = useState(false);
const { data } = useQuery({
queryKey: ["announcement", type],
@@ -37,12 +39,16 @@ export default function Announcement({ type }: { type: "popup" | "pinned" }) {
enabled: !!user,
});
useEffect(() => {
if (type === "popup" && !!data) setOpen(true);
}, [data, type]);
if (!data) return null;
if (type === "popup") {
return (
<Dialog defaultOpen={!!data}>
<DialogContent className="sm:max-w-[425px]">
<Dialog onOpenChange={setOpen} open={open}>
<DialogContent className="max-h-[85vh] overflow-auto sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{data.title}</DialogTitle>
</DialogHeader>
+40 -16
View File
@@ -276,6 +276,9 @@ const CheckoutForm: React.FC<Omit<StripePaymentProps, "publishable_key">> = ({
const stripe = useStripe();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [qrCodeImageDataUrl, setQrCodeImageDataUrl] = useState<string | null>(
null
);
const [isSubmitted, setIsSubmitted] = useState(false);
const { t } = useTranslation("payment");
const qrCodeMap: Record<string, string> = {
@@ -328,12 +331,21 @@ const CheckoutForm: React.FC<Omit<StripePaymentProps, "publishable_key">> = ({
if (paymentIntent?.status === "requires_action") {
const nextAction = paymentIntent.next_action as any;
const qrUrl =
method === "alipay"
? nextAction?.alipay_handle_redirect?.url
: nextAction?.wechat_pay_display_qr_code?.image_url_svg;
// Stripe returns multiple WeChat QR-related fields.
// For native WeChat pay experience we should prefer the protocol data (weixin://...).
// Fallback to the provided base64 image if present.
if (method === "alipay") {
const qrUrl = nextAction?.alipay_handle_redirect?.url;
setQrCodeUrl(qrUrl || null);
setQrCodeImageDataUrl(null);
} else {
const wechat = nextAction?.wechat_pay_display_qr_code;
const data = wechat?.data; // e.g. weixin://wxpay/bizpayurl?pr=...
const imageDataUrl = wechat?.image_data_url; // data:image/png;base64,...
setQrCodeUrl(qrUrl || null);
setQrCodeUrl(data || null);
setQrCodeImageDataUrl(data ? null : imageDataUrl || null);
}
}
} catch (_error) {
handleError(t("stripe.error", "An error occurred"));
@@ -348,18 +360,30 @@ const CheckoutForm: React.FC<Omit<StripePaymentProps, "publishable_key">> = ({
<div className="min-w-80 text-left">
<CardPaymentForm clientSecret={client_secret} onError={handleError} />
</div>
) : qrCodeUrl ? (
) : qrCodeUrl || qrCodeImageDataUrl ? (
<>
<QRCodeCanvas
imageSettings={{
src: `./assets/payment/${method}.svg`,
width: 24,
height: 24,
excavate: true,
}}
size={208}
value={qrCodeUrl}
/>
{qrCodeImageDataUrl ? (
<img
alt={
qrCodeMap[method] || t(`qrcode.${method}`, `Scan with ${method}`)
}
className="mx-auto h-[208px] w-[208px]"
height={208}
src={qrCodeImageDataUrl}
width={208}
/>
) : (
<QRCodeCanvas
imageSettings={{
src: `./assets/payment/${method}.svg`,
width: 24,
height: 24,
excavate: true,
}}
size={208}
value={qrCodeUrl!}
/>
)}
<p className="mt-4 text-center text-muted-foreground">
{qrCodeMap[method] || t(`qrcode.${method}`, `Scan with ${method}`)}
</p>