-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 新增cdk兑换页面(没测 #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,394 @@ | ||||||||||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import { useState, useEffect } from "react"; | ||||||||||||||||||||||||||||||||||
| import Link from "next/link"; | ||||||||||||||||||||||||||||||||||
| import en from "../../locales/en.json"; | ||||||||||||||||||||||||||||||||||
| import zh from "../../locales/zh.json"; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| type Locale = "en" | "zh"; | ||||||||||||||||||||||||||||||||||
| type Theme = "light" | "dark" | "system"; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const locales = { en, zh }; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // API 配置 | ||||||||||||||||||||||||||||||||||
| const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| interface ExchangeResult { | ||||||||||||||||||||||||||||||||||
| original_cdk: string; | ||||||||||||||||||||||||||||||||||
| new_cdk: string; | ||||||||||||||||||||||||||||||||||
| points: number; | ||||||||||||||||||||||||||||||||||
| remaining_days: number; | ||||||||||||||||||||||||||||||||||
| expired_at: string; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Icons | ||||||||||||||||||||||||||||||||||
| const SunIcon = () => ( | ||||||||||||||||||||||||||||||||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5"> | ||||||||||||||||||||||||||||||||||
| <circle cx="12" cy="12" r="5"/> | ||||||||||||||||||||||||||||||||||
| <line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> | ||||||||||||||||||||||||||||||||||
| <line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/> | ||||||||||||||||||||||||||||||||||
| <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const MoonIcon = () => ( | ||||||||||||||||||||||||||||||||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5"> | ||||||||||||||||||||||||||||||||||
| <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> | ||||||||||||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const LanguageIcon = () => ( | ||||||||||||||||||||||||||||||||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5"> | ||||||||||||||||||||||||||||||||||
| <circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/> | ||||||||||||||||||||||||||||||||||
| <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/> | ||||||||||||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const CopyIcon = () => ( | ||||||||||||||||||||||||||||||||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5"> | ||||||||||||||||||||||||||||||||||
| <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> | ||||||||||||||||||||||||||||||||||
| <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/> | ||||||||||||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const CheckIcon = () => ( | ||||||||||||||||||||||||||||||||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5"> | ||||||||||||||||||||||||||||||||||
| <polyline points="20 6 9 17 4 12"/> | ||||||||||||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const CloseIcon = () => ( | ||||||||||||||||||||||||||||||||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-6 h-6"> | ||||||||||||||||||||||||||||||||||
| <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> | ||||||||||||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const GiftIcon = () => ( | ||||||||||||||||||||||||||||||||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-12 h-12"> | ||||||||||||||||||||||||||||||||||
| <polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/> | ||||||||||||||||||||||||||||||||||
| <line x1="12" y1="22" x2="12" y2="7"/> | ||||||||||||||||||||||||||||||||||
| <path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/> | ||||||||||||||||||||||||||||||||||
| <path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/> | ||||||||||||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| function getSystemLocale(): Locale { | ||||||||||||||||||||||||||||||||||
| if (typeof navigator === "undefined") return "en"; | ||||||||||||||||||||||||||||||||||
| const lang = navigator.language.toLowerCase(); | ||||||||||||||||||||||||||||||||||
| if (lang.startsWith("zh")) return "zh"; | ||||||||||||||||||||||||||||||||||
| return "en"; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export default function ExchangePage() { | ||||||||||||||||||||||||||||||||||
| const [locale, setLocale] = useState<Locale>("en"); | ||||||||||||||||||||||||||||||||||
| const [theme, setTheme] = useState<Theme>("system"); | ||||||||||||||||||||||||||||||||||
| const [mounted, setMounted] = useState(false); | ||||||||||||||||||||||||||||||||||
| const [cdk, setCdk] = useState(""); | ||||||||||||||||||||||||||||||||||
| const [loading, setLoading] = useState(false); | ||||||||||||||||||||||||||||||||||
| const [error, setError] = useState(""); | ||||||||||||||||||||||||||||||||||
| const [result, setResult] = useState<ExchangeResult | null>(null); | ||||||||||||||||||||||||||||||||||
| const [showModal, setShowModal] = useState(false); | ||||||||||||||||||||||||||||||||||
| const [copied, setCopied] = useState(false); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const t = locales[locale]; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||
| setMounted(true); | ||||||||||||||||||||||||||||||||||
| const savedLocale = localStorage.getItem("locale") as Locale | null; | ||||||||||||||||||||||||||||||||||
| if (savedLocale && (savedLocale === "en" || savedLocale === "zh")) { | ||||||||||||||||||||||||||||||||||
| setLocale(savedLocale); | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| setLocale(getSystemLocale()); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| const savedTheme = localStorage.getItem("theme") as Theme | null; | ||||||||||||||||||||||||||||||||||
| if (savedTheme) { | ||||||||||||||||||||||||||||||||||
| setTheme(savedTheme); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||
| if (!mounted) return; | ||||||||||||||||||||||||||||||||||
| const applyTheme = (isDark: boolean) => { | ||||||||||||||||||||||||||||||||||
| document.documentElement.classList.toggle("dark", isDark); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
| if (theme === "system") { | ||||||||||||||||||||||||||||||||||
| const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); | ||||||||||||||||||||||||||||||||||
| applyTheme(mediaQuery.matches); | ||||||||||||||||||||||||||||||||||
| const handler = (e: MediaQueryListEvent) => applyTheme(e.matches); | ||||||||||||||||||||||||||||||||||
| mediaQuery.addEventListener("change", handler); | ||||||||||||||||||||||||||||||||||
| return () => mediaQuery.removeEventListener("change", handler); | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| applyTheme(theme === "dark"); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }, [theme, mounted]); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const toggleTheme = () => { | ||||||||||||||||||||||||||||||||||
| const newTheme = theme === "dark" ? "light" : theme === "light" ? "system" : "dark"; | ||||||||||||||||||||||||||||||||||
| setTheme(newTheme); | ||||||||||||||||||||||||||||||||||
| localStorage.setItem("theme", newTheme); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const toggleLocale = () => { | ||||||||||||||||||||||||||||||||||
| const newLocale = locale === "en" ? "zh" : "en"; | ||||||||||||||||||||||||||||||||||
| setLocale(newLocale); | ||||||||||||||||||||||||||||||||||
| localStorage.setItem("locale", newLocale); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const getThemeIcon = () => { | ||||||||||||||||||||||||||||||||||
| if (theme === "dark") return <MoonIcon />; | ||||||||||||||||||||||||||||||||||
| if (theme === "light") return <SunIcon />; | ||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5"> | ||||||||||||||||||||||||||||||||||
| <circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/> | ||||||||||||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+25
to
+144
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const getErrorMessage = (code: number): string => { | ||||||||||||||||||||||||||||||||||
| switch (code) { | ||||||||||||||||||||||||||||||||||
| case 1002: | ||||||||||||||||||||||||||||||||||
| return t.exchange.errors.invalidFormat; | ||||||||||||||||||||||||||||||||||
| case 2001: | ||||||||||||||||||||||||||||||||||
| return t.exchange.errors.notFound; | ||||||||||||||||||||||||||||||||||
| case 3002: | ||||||||||||||||||||||||||||||||||
| return t.exchange.errors.alreadyExchanged; | ||||||||||||||||||||||||||||||||||
| case 3003: | ||||||||||||||||||||||||||||||||||
| return t.exchange.errors.expired; | ||||||||||||||||||||||||||||||||||
| case 3004: | ||||||||||||||||||||||||||||||||||
| return t.exchange.errors.tooShort; | ||||||||||||||||||||||||||||||||||
| case 5001: | ||||||||||||||||||||||||||||||||||
| case 5002: | ||||||||||||||||||||||||||||||||||
| return t.exchange.errors.serverError; | ||||||||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||||||||
| return t.exchange.errors.serverError; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const handleExchange = async () => { | ||||||||||||||||||||||||||||||||||
| if (!cdk.trim()) { | ||||||||||||||||||||||||||||||||||
| setError(t.exchange.errors.emptyCdk); | ||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| //验证 CDK 格式(20-32位字母数字) | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
| //验证 CDK 格式(20-32位字母数字) | |
| // Validate CDK format (20-32 alphanumeric characters) |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API call passes the CDK as a query parameter in a POST request. This is a security concern as query parameters may be logged in server logs, proxy logs, and browser history. Sensitive data like activation codes should be sent in the request body instead. Consider changing to: fetch(API_BASE_URL + "/points/exchange", { method: "POST", headers: {...}, body: JSON.stringify({ cdk: cdk.trim() }) })
| const response = await fetch(`${API_BASE_URL}/points/exchange?cdk=${encodeURIComponent(cdk.trim())}`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| const response = await fetch(`${API_BASE_URL}/points/exchange`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ cdk: cdk.trim() }), |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using document.execCommand('copy') is deprecated and may not work in all modern browsers. Since you already have a try-catch block with navigator.clipboard.writeText, the fallback should handle the error more gracefully or remove this deprecated approach entirely. Consider showing an error message to the user if clipboard access fails.
| // Fallback for older browsers | |
| const textArea = document.createElement("textarea"); | |
| textArea.value = result.new_cdk; | |
| document.body.appendChild(textArea); | |
| textArea.select(); | |
| document.execCommand("copy"); | |
| document.body.removeChild(textArea); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| // Handle clipboard access failure gracefully without using deprecated APIs | |
| setError( | |
| (t.exchange?.errors && | |
| (t.exchange.errors.clipboardFailed || | |
| t.exchange.errors.generic)) || | |
| "Failed to copy to clipboard. Please copy the code manually." | |
| ); |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The input field lacks proper validation feedback for accessibility. When an error occurs, the error message should be associated with the input using aria-describedby to ensure screen reader users are informed of the validation error. Consider adding an id to the error div and referencing it in the input's aria-describedby attribute.
| /> | |
| </div> | |
| {error && ( | |
| <div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-sm text-center"> | |
| aria-invalid={!!error} | |
| aria-describedby={error ? "cdk-error" : undefined} | |
| /> | |
| </div> | |
| {error && ( | |
| <div | |
| id="cdk-error" | |
| className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-sm text-center" | |
| > |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The modal dialog lacks proper ARIA attributes for accessibility. Add role="dialog", aria-modal="true", and aria-labelledby pointing to the success title to help screen reader users understand the modal context.
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The close button icon lacks accessible text for screen readers. Add an aria-label attribute such as aria-label="Close modal" to provide context for assistive technology users.
| className="absolute top-4 right-4 p-2 rounded-lg hover:bg-surface-light transition-colors text-foreground/50 hover:text-foreground" | |
| className="absolute top-4 right-4 p-2 rounded-lg hover:bg-surface-light transition-colors text-foreground/50 hover:text-foreground" | |
| aria-label={t.exchange.close} |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The modal backdrop does not trap focus within the modal dialog. When the modal is open, users can still tab to elements behind it. Consider adding focus trap functionality to ensure keyboard navigation stays within the modal, and set focus to the first interactive element when the modal opens. Also, the close button and backdrop should handle Escape key press for better accessibility.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API_BASE_URL fallback value "https://api.example.com" is a non-functional placeholder domain. This will cause all API requests to fail if the environment variable is not set. Consider using a more descriptive error or at least documenting that NEXT_PUBLIC_API_BASE_URL must be configured before deployment.