feat(referrals): rework reward engine + email-verified landing
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Reward engine:
- Recurring milestones (every 3 → +7d, every 5 → +14d) instead of one-time
  tiers capped at 5; idempotent + transactional grants serialised per
  referrer so concurrent qualifications can't double-count.
- Rewards now gated on the referred user's email verification
  (afterEmailVerification hook); already-verified referees (OAuth) qualify
  at apply time.
- Reward days banked as users.referral_credit_days when the referrer has no
  live subscription, consumed on next trial start / activation (no more
  silently lost rewards).
- Accurate cumulative rewardDays in stats; getMyReferrals returns referee
  name/masked email/status.

Hardening / cleanup:
- onConflictDoNothing makes apply idempotent (no unhandled unique violation).
- Anti-fraud: normalizeEmail blocks self-referral via gmail dot/+tag aliases.
- Collision-safe referral code generation at signup.
- Single apply path (welcome onboarding modal); removed duplicate calls in
  register + subscription pages. Input validation on the apply code.

Email verification UX:
- Verification link now lands on a dedicated /email-verified confirmation
  page instead of the deep-linked VIN/search page.

Schema: referrals.status + qualified_at, users.referral_credit_days (0009).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 13:28:32 +03:00
parent 125c6fc431
commit 3926e276fd
18 changed files with 6260 additions and 392 deletions

View File

@@ -31,6 +31,7 @@ import { Route as AuthResetPasswordRouteImport } from './routes/_auth/reset-pass
import { Route as AuthRegisterRouteImport } from './routes/_auth/register'
import { Route as AuthLoginRouteImport } from './routes/_auth/login'
import { Route as AuthForgotPasswordRouteImport } from './routes/_auth/forgot-password'
import { Route as AuthEmailVerifiedRouteImport } from './routes/_auth/email-verified'
import { Route as DashboardSubscriptionIndexRouteImport } from './routes/dashboard/subscription/index'
import { Route as DashboardCatalogIndexRouteImport } from './routes/dashboard/catalog/index'
import { Route as DashboardAdminIndexRouteImport } from './routes/dashboard/admin/index'
@@ -160,6 +161,11 @@ const AuthForgotPasswordRoute = AuthForgotPasswordRouteImport.update({
path: '/forgot-password',
getParentRoute: () => AuthRoute,
} as any)
const AuthEmailVerifiedRoute = AuthEmailVerifiedRouteImport.update({
id: '/email-verified',
path: '/email-verified',
getParentRoute: () => AuthRoute,
} as any)
const DashboardSubscriptionIndexRoute =
DashboardSubscriptionIndexRouteImport.update({
id: '/subscription/',
@@ -280,6 +286,7 @@ export interface FileRoutesByFullPath {
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/email-verified': typeof AuthEmailVerifiedRoute
'/forgot-password': typeof AuthForgotPasswordRoute
'/login': typeof AuthLoginRoute
'/register': typeof AuthRegisterRoute
@@ -321,6 +328,7 @@ export interface FileRoutesByTo {
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/email-verified': typeof AuthEmailVerifiedRoute
'/forgot-password': typeof AuthForgotPasswordRoute
'/login': typeof AuthLoginRoute
'/register': typeof AuthRegisterRoute
@@ -365,6 +373,7 @@ export interface FileRoutesById {
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/_auth/email-verified': typeof AuthEmailVerifiedRoute
'/_auth/forgot-password': typeof AuthForgotPasswordRoute
'/_auth/login': typeof AuthLoginRoute
'/_auth/register': typeof AuthRegisterRoute
@@ -409,6 +418,7 @@ export interface FileRouteTypes {
| '/pricing'
| '/privacy'
| '/terms'
| '/email-verified'
| '/forgot-password'
| '/login'
| '/register'
@@ -450,6 +460,7 @@ export interface FileRouteTypes {
| '/pricing'
| '/privacy'
| '/terms'
| '/email-verified'
| '/forgot-password'
| '/login'
| '/register'
@@ -493,6 +504,7 @@ export interface FileRouteTypes {
| '/pricing'
| '/privacy'
| '/terms'
| '/_auth/email-verified'
| '/_auth/forgot-password'
| '/_auth/login'
| '/_auth/register'
@@ -696,6 +708,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthForgotPasswordRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/email-verified': {
id: '/_auth/email-verified'
path: '/email-verified'
fullPath: '/email-verified'
preLoaderRoute: typeof AuthEmailVerifiedRouteImport
parentRoute: typeof AuthRoute
}
'/dashboard/subscription/': {
id: '/dashboard/subscription/'
path: '/subscription'
@@ -833,6 +852,7 @@ declare module '@tanstack/react-router' {
}
interface AuthRouteChildren {
AuthEmailVerifiedRoute: typeof AuthEmailVerifiedRoute
AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute
AuthLoginRoute: typeof AuthLoginRoute
AuthRegisterRoute: typeof AuthRegisterRoute
@@ -840,6 +860,7 @@ interface AuthRouteChildren {
}
const AuthRouteChildren: AuthRouteChildren = {
AuthEmailVerifiedRoute: AuthEmailVerifiedRoute,
AuthForgotPasswordRoute: AuthForgotPasswordRoute,
AuthLoginRoute: AuthLoginRoute,
AuthRegisterRoute: AuthRegisterRoute,

View File

@@ -0,0 +1,43 @@
import { useAuthStore } from "@/stores/auth.store";
import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { CheckCircle2 } from "lucide-react";
export const Route = createFileRoute("/_auth/email-verified")({
component: EmailVerifiedPage,
});
function EmailVerifiedPage() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const isAuthed = !isLoading && !!user;
return (
<div className="space-y-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="flex size-16 items-center justify-center rounded-full bg-brand/10">
<CheckCircle2 className="size-9 text-brand" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">E-postanız doğrulandı</h1>
<p className="text-sm text-muted-foreground">
Hesabınız başarıyla doğrulandı. Artık tüm özellikleri kullanabilirsiniz.
</p>
</div>
</div>
{isAuthed ? (
<Button asChild className="w-full">
<Link to="/dashboard/search">Aramaya Başla</Link>
</Button>
) : (
<div className="space-y-3">
<Button asChild className="w-full">
<Link to="/login">Giriş Yap</Link>
</Button>
<p className="text-xs text-muted-foreground">Devam etmek için hesabınıza giriş yapın.</p>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { api } from "@/lib/api-client";
import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { track as trackMeta } from "@/lib/meta-pixel";
@@ -44,13 +43,9 @@ function RegisterPage() {
await signUp.email({ name, email, password, callbackURL: redirectUrl });
capture("user_signed_up", { method: "email" });
trackMeta("CompleteRegistration", { method: "email" });
if (refCode.trim()) {
try {
await api.post("/referrals/apply", { code: refCode.trim().toUpperCase() });
} catch {
// Geçersiz/kullanılmış kod — sessizce geç
}
}
// The referral code travels via `?ref=` in redirectUrl; the welcome
// onboarding modal on the search page is the single place that applies it
// (covers both email and Google OAuth signups).
toast.success("Hesap oluşturuldu!");
window.location.href = redirectUrl;
} catch {

View File

@@ -174,8 +174,6 @@ export function SubscriptionPage() {
// ─── Search-param-driven flags (read once) ─────────────────────────────────
const [welcome] = useState(() => search.welcome === "1");
const [initialRef] = useState(() => search.ref ?? null);
const hasAppliedRefRef = useRef(false);
const stripeResultRef = useRef(false);
// ─── Stepper state ─────────────────────────────────────────────────────────
@@ -299,12 +297,6 @@ export function SubscriptionPage() {
}
}, [subscription, currentPlanKey]);
useEffect(() => {
if (!initialRef || !subData || hasAppliedRefRef.current) return;
hasAppliedRefRef.current = true;
api.post("/referrals/apply", { code: initialRef.toUpperCase().trim() }).catch(() => {});
}, [initialRef, subData]);
useEffect(() => {
if (!welcome || !subData || hasFiredRef.current) return;
if (!eligibleForTrial) {