diff --git a/web-next/app/[locale]/vote-points/page.tsx b/web-next/app/[locale]/vote-points/page.tsx new file mode 100644 index 0000000..ecffcce --- /dev/null +++ b/web-next/app/[locale]/vote-points/page.tsx @@ -0,0 +1,21 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { getSession } from '@/lib/session' +import { getVoteSites } from '@/lib/vote' +import { VotePanel } from '@/components/VotePanel' + +export const dynamic = 'force-dynamic' + +export default async function VotePointsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('Vote') + const session = await getSession() + const sites = await getVoteSites() + return ( +
+

{t('title')}

+

{t('info', { server: 'Nova WoW' })}

+ +
+ ) +} diff --git a/web-next/app/api/vote/route.ts b/web-next/app/api/vote/route.ts new file mode 100644 index 0000000..58f782d --- /dev/null +++ b/web-next/app/api/vote/route.ts @@ -0,0 +1,16 @@ +import { getSession } from '@/lib/session' +import { registerVote } from '@/lib/vote' + +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + let url = '' + try { + const b = await request.json() + url = String(b.url ?? '') + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + if (!url) return Response.json({ success: false, error: 'siteNotFound' }) + return Response.json(await registerVote(session.accountId, url)) +} diff --git a/web-next/components/Header.tsx b/web-next/components/Header.tsx index 4b82ec2..86f261a 100644 --- a/web-next/components/Header.tsx +++ b/web-next/components/Header.tsx @@ -22,6 +22,9 @@ export async function Header() { {t('forum')} + + {t('vote')} + {loggedIn ? ( <> diff --git a/web-next/components/VotePanel.tsx b/web-next/components/VotePanel.tsx new file mode 100644 index 0000000..94e0990 --- /dev/null +++ b/web-next/components/VotePanel.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import type { VoteSite } from '@/lib/vote' + +export function VotePanel({ sites, canVote }: { sites: VoteSite[]; canVote: boolean }) { + const t = useTranslations('Vote') + const [busyId, setBusyId] = useState(null) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + async function vote(site: VoteSite) { + if (busyId !== null) return + window.open(site.url, '_blank', 'noopener,noreferrer') + setBusyId(site.id) + setMessage(null) + try { + const res = await fetch('/api/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ url: site.url }), + }) + const d: { success?: boolean; error?: string; points?: number; siteName?: string; hours?: number; minutes?: number } = + await res.json() + if (d.success) { + setMessage({ ok: true, text: t('success', { site: d.siteName ?? site.name, points: d.points ?? 0 }) }) + } else if (d.error === 'cooldown') { + setMessage({ ok: false, text: t('cooldown', { hours: d.hours ?? 0, minutes: d.minutes ?? 0 }) }) + } else { + setMessage({ ok: false, text: t(d.error === 'siteNotFound' ? 'siteNotFound' : 'genericError') }) + } + } catch { + setMessage({ ok: false, text: t('genericError') }) + } finally { + setBusyId(null) + } + } + + if (sites.length === 0) return

{t('noSites')}

+ + return ( + <> +
+ {sites.map((s) => ( +
+ {s.image_url && {s.name}} +

{s.name}

+

+ {s.points} {t('pv')} +

+ {canVote ? ( + + ) : ( + + {t('vote')} + + )} +
+ ))} +
+ {message &&

{message.text}

} + {!canVote &&

{t('loginToVote')}

} + + ) +} diff --git a/web-next/lib/vote.ts b/web-next/lib/vote.ts new file mode 100644 index 0000000..4957fde --- /dev/null +++ b/web-next/lib/vote.ts @@ -0,0 +1,68 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' + +const COOLDOWN_MS = (12 * 60 + 30) * 60 * 1000 // 12h 30m + +export interface VoteSite { + id: number + name: string + url: string + image_url: string + points: number +} + +export interface VoteResult { + success: boolean + error?: string + points?: number + siteName?: string + hours?: number + minutes?: number +} + +export async function getVoteSites(): Promise { + try { + const [rows] = await db(DB.default).query( + 'SELECT id, name, url, image_url, points FROM home_votesite ORDER BY id', + ) + return rows.map((r) => ({ id: r.id, name: r.name, url: r.url, image_url: r.image_url, points: r.points })) + } catch { + return [] + } +} + +export async function registerVote(accountId: number, url: string): Promise { + const [siteRows] = await db(DB.default).query( + 'SELECT id, name, points FROM home_votesite WHERE url = ?', + [url], + ) + const site = siteRows[0] + if (!site) return { success: false, error: 'siteNotFound' } + + const [last] = await db(DB.default).query( + 'SELECT created_at FROM home_votelog WHERE account_id = ? AND vote_site_id = ? ORDER BY created_at DESC LIMIT 1', + [accountId, site.id], + ) + if (last[0]) { + const diff = Date.now() - new Date(last[0].created_at).getTime() + if (diff < COOLDOWN_MS) { + const rem = COOLDOWN_MS - diff + return { success: false, error: 'cooldown', hours: Math.floor(rem / 3600000), minutes: Math.floor((rem % 3600000) / 60000) } + } + } + + // Acreditar PV + const [pts] = await db(DB.default).query('SELECT id FROM home_api_points WHERE accountID = ?', [accountId]) + if (pts[0]) { + await db(DB.default).query('UPDATE home_api_points SET vp = vp + ? WHERE accountID = ?', [site.points, accountId]) + } else { + await db(DB.default).query('INSERT INTO home_api_points (accountID, vp, dp) VALUES (?, ?, 0)', [accountId, site.points]) + } + + await db(DB.default).query( + 'INSERT INTO home_votelog (account_id, vote_site_id, created_at, last_vote_time, processed) VALUES (?, ?, NOW(), NOW(), 0)', + [accountId, site.id], + ) + + return { success: true, points: site.points, siteName: site.name } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index ce7e4ce..1f396b7 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -10,7 +10,8 @@ "account": "My account", "logout": "Log out", "language": "Language", - "forum": "Forums" + "forum": "Forums", + "vote": "Vote" }, "Home": { "brand": "Nova WoW", @@ -300,5 +301,18 @@ "voteImage": "Image URL", "votePoints": "VP per vote", "noVotes": "No vote sites." + }, + "Vote": { + "title": "Vote for us", + "info": "Vote for {server} on the sites below and earn VP. You can vote on each site every 12 hours.", + "vote": "Vote", + "voting": "Voting…", + "pv": "VP", + "success": "Thanks for voting on {site}! {points} VP credited.", + "cooldown": "You must wait {hours}h {minutes}m to vote again on this site.", + "siteNotFound": "Vote site not found.", + "genericError": "Something went wrong. Please try again later.", + "noSites": "No vote sites configured yet.", + "loginToVote": "Sign in to vote." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 840edc4..2255bc9 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -10,7 +10,8 @@ "account": "Mi cuenta", "logout": "Cerrar sesión", "language": "Idioma", - "forum": "Foros" + "forum": "Foros", + "vote": "Votar" }, "Home": { "brand": "Nova WoW", @@ -300,5 +301,18 @@ "voteImage": "URL de la imagen", "votePoints": "PV por voto", "noVotes": "No hay sitios de voto." + }, + "Vote": { + "title": "Votar por nosotros", + "info": "Vota por {server} en las siguientes páginas y recibe PV. Puedes votar en cada sitio cada 12 horas.", + "vote": "Votar", + "voting": "Votando…", + "pv": "PV", + "success": "¡Gracias por votar en {site}! Se han acreditado {points} PV.", + "cooldown": "Debes esperar {hours}h {minutes}m para volver a votar en este sitio.", + "siteNotFound": "Sitio de votación no encontrado.", + "genericError": "Algo ha salido mal. Inténtalo más tarde.", + "noSites": "No hay sitios de votación configurados todavía.", + "loginToVote": "Inicia sesión para votar." } }