feat(referrals): rework reward engine + email-verified landing
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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:
43
apps/web/src/routes/_auth/email-verified.tsx
Normal file
43
apps/web/src/routes/_auth/email-verified.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user