Skip to content

Commit 958520f

Browse files
authored
Merge branch 'master' into feat/seo-meta-tags
2 parents ee224ca + ba539af commit 958520f

63 files changed

Lines changed: 14731 additions & 674 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/.env.example

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,15 @@ THROTTLE_DEFAULT_LIMIT=100 # max requests per window
6363
THROTTLE_DEFAULT_TTL=60 # window size in seconds
6464

6565
# Auth tier — POST /auth/verify (brute-force protection)
66-
THROTTLE_AUTH_LIMIT=10
66+
# 5 req/min is intentionally strict to prevent signature/nonce guessing attacks.
67+
# Raise only if legitimate users are being blocked (e.g. automated wallet flows).
68+
THROTTLE_AUTH_LIMIT=5
6769
THROTTLE_AUTH_TTL=60
6870

6971
# Nonce tier — GET /auth/nonce
70-
THROTTLE_NONCE_LIMIT=30
72+
# 5 req/min prevents nonce-exhaustion attacks (each call allocates storage).
73+
# Normal sign-in flows require at most 1–2 nonce requests per session.
74+
THROTTLE_NONCE_LIMIT=5
7175
THROTTLE_NONCE_TTL=60
7276

7377
# ── Backup ────────────────────────────────────────────────────────────────────
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- #169 Optimize search with PostgreSQL GIN indexes
2+
-- Adds a generated tsvector column for full-text search across title, description, and category.
3+
-- A GIN index is created on this column to optimize search performance.
4+
5+
-- 1. Add the search_vector column as a generated column
6+
-- We use 'english' dictionary for stemming and setweight for ranking (Title > Description > Category)
7+
ALTER TABLE raffle_metadata
8+
ADD COLUMN IF NOT EXISTS search_vector tsvector
9+
GENERATED ALWAYS AS (
10+
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
11+
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
12+
setweight(to_tsvector('english', coalesce(category, '')), 'C')
13+
) STORED;
14+
15+
-- 2. Create the GIN index for efficient full-text search
16+
CREATE INDEX IF NOT EXISTS idx_raffle_metadata_search_vector ON raffle_metadata USING GIN (search_vector);
17+
18+
-- 3. Analyze query performance (Manual step for DBA)
19+
-- EXPLAIN ANALYZE SELECT * FROM raffle_metadata WHERE search_vector @@ websearch_to_tsquery('english', 'search terms');

backend/docs/SEARCH_STRATEGY.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Search Optimization Strategy
2+
3+
To optimize full-text search for raffles, we have implemented PostgreSQL GIN (Generalized Inverted Index) indexes on the `raffle_metadata` table.
4+
5+
## Implementation Details
6+
7+
### 1. tsvector Column
8+
We added a generated column `search_vector` of type `tsvector`. This column automatically aggregates and tokenizes text from the following columns:
9+
- `title` (Weight A - Highest priority)
10+
- `description` (Weight B)
11+
- `category` (Weight C - Lowest priority)
12+
13+
The `english` dictionary is used for stemming (e.g., "raffles" matches "raffle").
14+
15+
### 2. GIN Index
16+
A GIN index `idx_raffle_metadata_search_vector` was created on the `search_vector` column. Unlike B-tree indexes, GIN indexes are designed for composite values (like document vectors) and allow for very fast full-text searching.
17+
18+
### 3. Querying
19+
The backend was updated to use the `@@` operator (via Supabase's `.textSearch()`) instead of the expensive `ilike %pattern%` operator. We use the `websearch` type to support advanced search syntax:
20+
- `"exact phrase"`
21+
- `word1 -word2` (exclude word2)
22+
- `word1 OR word2`
23+
24+
## Performance Analysis
25+
To verify performance improvements, run the following in the database console:
26+
27+
```sql
28+
EXPLAIN ANALYZE
29+
SELECT *
30+
FROM raffle_metadata
31+
WHERE search_vector @@ websearch_to_tsquery('english', 'your search query');
32+
```
33+
34+
Expected results:
35+
- **Index Scan** instead of **Sequential Scan**.
36+
- Significant reduction in execution time as the number of raffles grows.

backend/src/app.module.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ import { validate } from "./config/env.schema";
3030
* Tier Limit Window Applies to
3131
* ──────────────────────────────────────────────────────────────
3232
* default 100 req 60 s All public endpoints
33-
* auth 10 req 60 s POST /auth/verify
34-
* nonce 30 req 60 s GET /auth/nonce
33+
* auth 5 req 60 s POST /auth/verify
34+
* nonce 5 req 60 s GET /auth/nonce
35+
*
36+
* The auth and nonce tiers are overridden at the controller level
37+
* via @Throttle() — the values here serve as the fallback defaults
38+
* and can be tuned via env vars without a redeploy.
3539
*
3640
* Override limits via env vars (see .env.example):
3741
* THROTTLE_DEFAULT_LIMIT / THROTTLE_DEFAULT_TTL
@@ -50,12 +54,12 @@ import { validate } from "./config/env.schema";
5054
},
5155
{
5256
name: "auth",
53-
limit: config.get<number>("THROTTLE_AUTH_LIMIT", 10),
57+
limit: config.get<number>("THROTTLE_AUTH_LIMIT", 5),
5458
ttl: seconds(config.get<number>("THROTTLE_AUTH_TTL", 60)),
5559
},
5660
{
5761
name: "nonce",
58-
limit: config.get<number>("THROTTLE_NONCE_LIMIT", 30),
62+
limit: config.get<number>("THROTTLE_NONCE_LIMIT", 5),
5963
ttl: seconds(config.get<number>("THROTTLE_NONCE_TTL", 60)),
6064
},
6165
],

backend/src/auth/auth.controller.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ export class AuthController {
3030
/**
3131
* GET /auth/nonce?address=G... — Get signing nonce for SIWS.
3232
*
33-
* Rate limit: 30 req / 60 s per IP (nonce tier).
34-
* Stricter than the default tier because this endpoint is stateful
35-
* (each call stores a nonce in memory/DB) and could be used to
36-
* exhaust nonce storage if left unlimited.
33+
* Rate limit: 5 req / 60 s per IP (nonce tier).
34+
* Strict because each call allocates a nonce in storage; low limit
35+
* prevents nonce-exhaustion attacks.
36+
* Override via THROTTLE_NONCE_LIMIT / THROTTLE_NONCE_TTL env vars.
3737
*/
38-
@Throttle({ nonce: { limit: 30, ttl: 60000 } })
38+
@Throttle({ nonce: { limit: 5, ttl: 60000 } })
3939
@Get("nonce")
4040
@ApiOperation({ summary: "Get signing nonce for SIWS" })
4141
@ApiQuery({ name: "address", description: "Stellar address of the user" })
@@ -48,10 +48,11 @@ export class AuthController {
4848
* POST /auth/verify — Verify wallet signature, issue JWT.
4949
* Body: { address, signature, nonce [, issuedAt] }
5050
*
51-
* Rate limit: 10 req / 60 s per IP (auth tier).
52-
* Very strict — prevents brute-force signature/nonce guessing.
51+
* Rate limit: 5 req / 60 s per IP (auth tier).
52+
* Strict brute-force protection — prevents signature/nonce guessing.
53+
* Override via THROTTLE_AUTH_LIMIT / THROTTLE_AUTH_TTL env vars.
5354
*/
54-
@Throttle({ auth: { limit: 10, ttl: 60000 } })
55+
@Throttle({ auth: { limit: 5, ttl: 60000 } })
5556
@Post("verify")
5657
@ApiOperation({ summary: "Verify wallet signature and issue JWT" })
5758
@UsePipes(new (createZodPipe(VerifyBodySchema))())

backend/src/services/metadata.service.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,27 @@ export class MetadataService {
8484
limit = 20,
8585
offset = 0,
8686
): Promise<SearchMetadataResult> {
87-
const pattern = `%${query}%`;
87+
// If query is empty, fall back to basic listing or return empty
88+
if (!query.trim()) {
89+
const { data, error, count } = await this.client
90+
.from(TABLE)
91+
.select('*', { count: 'exact' })
92+
.order('updated_at', { ascending: false })
93+
.range(offset, offset + limit - 1);
94+
95+
if (error) throw new Error(`Fetch failed: ${error.message}`);
96+
return { matches: (data ?? []) as RaffleMetadata[], total: count ?? 0 };
97+
}
8898

99+
// Use PostgreSQL full-text search via the search_vector column and GIN index
100+
// 'websearch' type allows for intuitive query syntax (e.g., quotes for phrases, - for exclusion)
89101
const { data, error, count } = await this.client
90102
.from(TABLE)
91103
.select('*', { count: 'exact' })
92-
.or(`title.ilike.${pattern},description.ilike.${pattern},category.ilike.${pattern}`)
104+
.textSearch('search_vector', query, {
105+
config: 'english',
106+
type: 'websearch',
107+
})
93108
.range(offset, offset + limit - 1);
94109

95110
if (error) {

client/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,15 @@
2121
"@stellar/stellar-sdk": "^14.4.3",
2222
"@supabase/supabase-js": "^2.76.1",
2323
"@tailwindcss/vite": "^4.1.13",
24+
"i18next": "^26.0.7",
25+
"i18next-browser-languagedetector": "^8.2.1",
26+
"i18next-http-backend": "^3.0.6",
27+
"canvas-confetti": "^1.9.4",
2428
"lucide-react": "^0.544.0",
2529
"react": "^19.2.0",
2630
"react-dom": "^19.2.0",
2731
"react-helmet-async": "^3.0.0",
32+
"react-i18next": "^17.0.4",
2833
"react-router-dom": "^7.13.2",
2934
"recharts": "^3.8.1",
3035
"sonner": "^2.0.7",
@@ -36,6 +41,7 @@
3641
"@playwright/test": "^1.43.0",
3742
"@testing-library/jest-dom": "^5.16.5",
3843
"@testing-library/react": "^13.4.0",
44+
"@types/canvas-confetti": "^1.9.0",
3945
"@types/node": "^25.0.9",
4046
"@types/react": "^19.1.10",
4147
"@types/react-dom": "^19.1.7",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"navbar": {
3+
"discover": "Discover Raffles",
4+
"create": "Create Raffle",
5+
"myRaffles": "My Raffles",
6+
"leaderboard": "Leaderboard",
7+
"settings": "Settings",
8+
"searchPlaceholder": "Search raffles...",
9+
"switchTo": "Switch to {{network}}",
10+
"getStarted": "Get Started"
11+
},
12+
"home": {
13+
"catchNextOpportunity": "Catch Your Next Opportunity",
14+
"exploreTrending": "Explore New Trending Raffles",
15+
"seeAll": "See All",
16+
"failedToLoad": "Failed to load raffles",
17+
"noActiveRaffles": "No active raffles found",
18+
"beTheFirst": "Be the first to create a raffle!",
19+
"loading": "Loading...",
20+
"loadMore": "Load More"
21+
},
22+
"raffle": {
23+
"errorLoading": "Error Loading Raffle",
24+
"notFound": "Raffle Not Found",
25+
"notFoundMessage": "The raffle you're looking for doesn't exist or has been removed.",
26+
"backToHome": "Back to Home",
27+
"back": "Back",
28+
"liveNow": "Live Now",
29+
"finalized": "Finalized",
30+
"ended": "Ended",
31+
"createdBy": "Created by",
32+
"about": "About this raffle",
33+
"noDescription": "No description provided by the creator.",
34+
"prize": "Prize",
35+
"started": "Started",
36+
"network": "Network",
37+
"ticketPrice": "Ticket Price",
38+
"progress": "Progress",
39+
"sold": "sold",
40+
"endsIn": "Ends In",
41+
"totalParticipants": "Total Participants",
42+
"unique": "unique",
43+
"buyFor": "BUY FOR {{cost}} {{currency}}",
44+
"secureCheckout": "Secure checkout via Stellar Toolkit",
45+
"winner": "Raffle Winner",
46+
"viewProof": "View Proof",
47+
"noWinnerYet": "No winner announced yet",
48+
"participationClosed": "Participation Closed",
49+
"provablyFair": "Provably Fair",
50+
"fairnessDetail": "Winner selection uses Soroban VRF for ultimate transparency and fairness.",
51+
"stayUpdated": "Stay Updated",
52+
"getNotified": "Get notified when this raffle ends or you win"
53+
}
54+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"navbar": {
3+
"discover": "Descubrir Sorteos",
4+
"create": "Crear Sorteo",
5+
"myRaffles": "Mis Sorteos",
6+
"leaderboard": "Clasificación",
7+
"settings": "Ajustes",
8+
"searchPlaceholder": "Buscar sorteos...",
9+
"switchTo": "Cambiar a {{network}}",
10+
"getStarted": "Empezar"
11+
},
12+
"home": {
13+
"catchNextOpportunity": "Atrapa tu próxima oportunidad",
14+
"exploreTrending": "Explora los nuevos sorteos en tendencia",
15+
"seeAll": "Ver todo",
16+
"failedToLoad": "Error al cargar los sorteos",
17+
"noActiveRaffles": "No se encontraron sorteos activos",
18+
"beTheFirst": "¡Sé el primero en crear un sorteo!",
19+
"loading": "Cargando...",
20+
"loadMore": "Cargar más"
21+
},
22+
"raffle": {
23+
"errorLoading": "Error al cargar el sorteo",
24+
"notFound": "Sorteo no encontrado",
25+
"notFoundMessage": "El sorteo que buscas no existe o ha sido eliminado.",
26+
"backToHome": "Volver al inicio",
27+
"back": "Atrás",
28+
"liveNow": "En vivo",
29+
"finalized": "Finalizado",
30+
"ended": "Terminado",
31+
"createdBy": "Creado por",
32+
"about": "Acerca de este sorteo",
33+
"noDescription": "No hay descripción proporcionada por el creador.",
34+
"prize": "Premio",
35+
"started": "Empezó",
36+
"network": "Red",
37+
"ticketPrice": "Precio del ticket",
38+
"progress": "Progreso",
39+
"sold": "vendidos",
40+
"endsIn": "Termina en",
41+
"totalParticipants": "Participantes totales",
42+
"unique": "únicos",
43+
"buyFor": "COMPRAR POR {{cost}} {{currency}}",
44+
"secureCheckout": "Pago seguro vía Stellar Toolkit",
45+
"winner": "Ganador del sorteo",
46+
"viewProof": "Ver prueba",
47+
"noWinnerYet": "Aún no se ha anunciado un ganador",
48+
"participationClosed": "Participación cerrada",
49+
"provablyFair": "Probablemente justo",
50+
"fairnessDetail": "La selección del ganador utiliza Soroban VRF para una máxima transparencia y justicia.",
51+
"stayUpdated": "Mantente actualizado",
52+
"getNotified": "Recibe notificaciones cuando termine este sorteo o si ganas"
53+
}
54+
}

client/src/components/Navbar.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import NotificationBellIcon from "./cards/NotificationBellIcon";
1010
import { Search } from "lucide-react";
1111
import { useWalletContext } from "../providers/WalletProvider";
1212
import { STELLAR_CONFIG } from "../config/stellar";
13+
import { useTranslation } from "react-i18next";
14+
import { Globe } from "lucide-react";
1315

1416

1517
const Navbar = ({ onStart }: { onStart?: () => void }) => {
18+
const { t, i18n } = useTranslation();
1619
const [open, setOpen] = React.useState(false);
1720
const { isConnected, isWrongNetwork, switchNetwork } = useWalletContext();
1821

@@ -55,11 +58,11 @@ const Navbar = ({ onStart }: { onStart?: () => void }) => {
5558
}, [location.pathname]);
5659

5760
const navItems = [
58-
{ label: "Discover Raffles", href: "/home" },
59-
{ label: "Create Raffle", href: "/create" },
60-
{ label: "My Raffles", href: "/my-raffles" },
61-
{ label: "Leaderboard", href: "/leaderboard" },
62-
{ label: "Settings", href: "/settings" },
61+
{ label: t("navbar.discover"), href: "/home" },
62+
{ label: t("navbar.create"), href: "/create" },
63+
{ label: t("navbar.myRaffles"), href: "/my-raffles" },
64+
{ label: t("navbar.leaderboard"), href: "/leaderboard" },
65+
{ label: t("navbar.settings"), href: "/settings" },
6366
];
6467

6568
const targetNetwork = STELLAR_CONFIG.network.charAt(0).toUpperCase() + STELLAR_CONFIG.network.slice(1);
@@ -81,7 +84,7 @@ const Navbar = ({ onStart }: { onStart?: () => void }) => {
8184
type="text"
8285
value={searchValue}
8386
onChange={(e) => setSearchValue(e.target.value)}
84-
placeholder="Search raffles..."
87+
placeholder={t("navbar.searchPlaceholder")}
8588
className="w-full bg-gray-200 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl px-4 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-600 dark:text-white/40 focus:outline-none focus:ring-1 focus:ring-[#FE3796] transition-all"
8689
/>
8790
<Search
@@ -119,7 +122,7 @@ const Navbar = ({ onStart }: { onStart?: () => void }) => {
119122
className="flex items-center gap-2 rounded-full border border-red-500/50 bg-red-500/10 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/20 transition"
120123
>
121124
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-red-500 animate-pulse" />
122-
Switch to {targetNetwork}
125+
{t("navbar.switchTo", { network: targetNetwork })}
123126
</button>
124127
) : (
125128
<div className="flex items-center gap-2 rounded-full border border-[#52E5A4]/30 bg-[#52E5A4]/5 px-3 py-1.5 text-xs font-medium text-[#52E5A4]">
@@ -130,6 +133,21 @@ const Navbar = ({ onStart }: { onStart?: () => void }) => {
130133
</div>
131134
)}
132135

136+
<div className="flex items-center gap-2 px-2 py-1 rounded-lg bg-gray-100 dark:bg-white/5">
137+
<button
138+
onClick={() => i18n.changeLanguage("en")}
139+
className={`px-2 py-1 text-xs font-medium rounded ${i18n.language === "en" ? "bg-[#FE3796] text-white" : "text-gray-500 hover:text-gray-900 dark:text-white/60"}`}
140+
>
141+
EN
142+
</button>
143+
<button
144+
onClick={() => i18n.changeLanguage("es")}
145+
className={`px-2 py-1 text-xs font-medium rounded ${i18n.language === "es" ? "bg-[#FE3796] text-white" : "text-gray-500 hover:text-gray-900 dark:text-white/60"}`}
146+
>
147+
ES
148+
</button>
149+
</div>
150+
133151
<ThemeToggle />
134152
<WalletButton />
135153
<SignInButton />
@@ -170,7 +188,7 @@ const Navbar = ({ onStart }: { onStart?: () => void }) => {
170188
type="text"
171189
value={searchValue}
172190
onChange={(e) => setSearchValue(e.target.value)}
173-
placeholder="Search raffles..."
191+
placeholder={t("navbar.searchPlaceholder")}
174192
className="w-full bg-gray-200 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl px-4 py-3 text-gray-900 dark:text-white placeholder:text-gray-600 dark:text-white/40 focus:outline-none"
175193
/>
176194
</div>
@@ -199,7 +217,7 @@ const Navbar = ({ onStart }: { onStart?: () => void }) => {
199217
}}
200218
className="mt-2 rounded-xl px-6 py-3 text-center text-sm font-medium text-gray-900 dark:text-white hover:brightness-110 bg-[#FE3796] cursor-pointer"
201219
>
202-
Get Started
220+
{t("navbar.getStarted")}
203221
</a>
204222
<ThemeToggle />
205223
</div>

0 commit comments

Comments
 (0)