feat: yıllık abonelik (varsayılan) — Stripe yıllık fiyatlar + periyot checkout'a geçiyor
This commit is contained in:
+13
-4
@@ -51,6 +51,8 @@ export const PLANS = [
|
|||||||
],
|
],
|
||||||
sinirlar: { takip: null, ekip: 5 },
|
sinirlar: { takip: null, ekip: 5 },
|
||||||
priceEnv: 'STRIPE_PRICE_PRO',
|
priceEnv: 'STRIPE_PRICE_PRO',
|
||||||
|
priceEnvYillik: 'STRIPE_PRICE_PRO_YILLIK',
|
||||||
|
fiyatYillik: 8954,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'kurumsal',
|
id: 'kurumsal',
|
||||||
@@ -69,6 +71,8 @@ export const PLANS = [
|
|||||||
],
|
],
|
||||||
sinirlar: { takip: null, ekip: null },
|
sinirlar: { takip: null, ekip: null },
|
||||||
priceEnv: 'STRIPE_PRICE_KURUMSAL',
|
priceEnv: 'STRIPE_PRICE_KURUMSAL',
|
||||||
|
priceEnvYillik: 'STRIPE_PRICE_KURUMSAL_YILLIK',
|
||||||
|
fiyatYillik: 24800,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -118,6 +122,7 @@ export function planForPrice(priceId) {
|
|||||||
if (!priceId) return null;
|
if (!priceId) return null;
|
||||||
for (const p of PLANS) {
|
for (const p of PLANS) {
|
||||||
if (p.priceEnv && process.env[p.priceEnv] === priceId) return p.id;
|
if (p.priceEnv && process.env[p.priceEnv] === priceId) return p.id;
|
||||||
|
if (p.priceEnvYillik && process.env[p.priceEnvYillik] === priceId) return p.id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -126,6 +131,7 @@ export function planCatalog() {
|
|||||||
return PLANS.map((p) => ({
|
return PLANS.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
satinAlinabilir: Boolean(p.priceEnv && process.env[p.priceEnv] && billingConfigured()),
|
satinAlinabilir: Boolean(p.priceEnv && process.env[p.priceEnv] && billingConfigured()),
|
||||||
|
satinAlinabilirYillik: Boolean(p.priceEnvYillik && process.env[p.priceEnvYillik] && billingConfigured()),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,25 +147,28 @@ function formEncode(obj) {
|
|||||||
return parts.join('&');
|
return parts.join('&');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCheckoutSession({ planId, email }) {
|
export async function createCheckoutSession({ planId, email, periyot = 'yillik' }) {
|
||||||
const key = process.env.STRIPE_SECRET_KEY;
|
const key = process.env.STRIPE_SECRET_KEY;
|
||||||
if (!key) throw new Error('Ödeme yapılandırılmamış (STRIPE_SECRET_KEY yok)');
|
if (!key) throw new Error('Ödeme yapılandırılmamış (STRIPE_SECRET_KEY yok)');
|
||||||
const plan = PLANS.find((p) => p.id === planId);
|
const plan = PLANS.find((p) => p.id === planId);
|
||||||
if (!plan || !plan.priceEnv) throw new Error('Geçersiz plan');
|
if (!plan || !plan.priceEnv) throw new Error('Geçersiz plan');
|
||||||
const price = process.env[plan.priceEnv];
|
const yillik = periyot === 'yillik';
|
||||||
if (!price) throw new Error(`${plan.ad} planı için ${plan.priceEnv} tanımlı değil`);
|
const env = yillik && plan.priceEnvYillik ? plan.priceEnvYillik : plan.priceEnv;
|
||||||
|
const price = process.env[env];
|
||||||
|
if (!price) throw new Error(`${plan.ad} planı için ${env} tanımlı değil`);
|
||||||
|
|
||||||
const appUrl = process.env.APP_URL || 'https://ihaleist.seymata.com';
|
const appUrl = process.env.APP_URL || 'https://ihaleist.seymata.com';
|
||||||
const body = formEncode({
|
const body = formEncode({
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
'line_items[0][price]': price,
|
'line_items[0][price]': price,
|
||||||
'line_items[0][quantity]': 1,
|
'line_items[0][quantity]': 1,
|
||||||
success_url: `${appUrl}/?odeme=basarili&plan=${planId}`,
|
success_url: `${appUrl}/?odeme=basarili&plan=${planId}&periyot=${periyot}`,
|
||||||
cancel_url: `${appUrl}/?odeme=iptal`,
|
cancel_url: `${appUrl}/?odeme=iptal`,
|
||||||
customer_email: email,
|
customer_email: email,
|
||||||
allow_promotion_codes: 'true',
|
allow_promotion_codes: 'true',
|
||||||
client_reference_id: email || '',
|
client_reference_id: email || '',
|
||||||
'metadata[planId]': planId,
|
'metadata[planId]': planId,
|
||||||
|
'metadata[periyot]': periyot,
|
||||||
'metadata[email]': email || '',
|
'metadata[email]': email || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -757,7 +757,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const out = await createCheckoutSession({ planId: body.planId, email: user.email });
|
const out = await createCheckoutSession({ planId: body.planId, email: user.email, periyot: body.periyot === 'aylik' ? 'aylik' : 'yillik' });
|
||||||
return json(res, 200, { success: true, ...out });
|
return json(res, 200, { success: true, ...out });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return json(res, billingConfigured() ? 400 : 503, { success: false, error: e.message });
|
return json(res, billingConfigured() ? 400 : 503, { success: false, error: e.message });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const Pricing = () => {
|
|||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [busy, setBusy] = useState(null);
|
const [busy, setBusy] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [yillik, setYillik] = useState(false);
|
const [yillik, setYillik] = useState(true); // yıllık varsayılan (indirimli)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/billing/plans')
|
fetch('/api/billing/plans')
|
||||||
@@ -24,7 +24,7 @@ export const Pricing = () => {
|
|||||||
const res = await fetch('/api/billing/checkout', {
|
const res = await fetch('/api/billing/checkout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||||
body: JSON.stringify({ planId }),
|
body: JSON.stringify({ planId, periyot: yillik ? 'yillik' : 'aylik' }),
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (d.success && d.url) window.location.href = d.url;
|
if (d.success && d.url) window.location.href = d.url;
|
||||||
@@ -37,7 +37,9 @@ export const Pricing = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const plans = data?.plans || [];
|
const plans = data?.plans || [];
|
||||||
const fiyatla = (p) => (yillik ? Math.round(p.fiyat * 12 * 0.83) : p.fiyat);
|
// Yıllık tutar Stripe'taki fiyatla birebir aynı olsun diye plandan gelir
|
||||||
|
const fiyatla = (p) => (yillik ? (p.fiyatYillik ?? Math.round(p.fiyat * 12 * 0.83)) : p.fiyat);
|
||||||
|
const aylikKarsiligi = (p) => (p.fiyatYillik ? Math.round(p.fiyatYillik / 12) : p.fiyat);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
@@ -57,12 +59,12 @@ export const Pricing = () => {
|
|||||||
{/* Aylık / Yıllık */}
|
{/* Aylık / Yıllık */}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex text-xs font-bold shadow-xs">
|
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex text-xs font-bold shadow-xs">
|
||||||
<button onClick={() => setYillik(false)} className={`px-4 py-2 rounded-lg transition-colors ${!yillik ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-slate-50'}`}>
|
|
||||||
Aylık
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setYillik(true)} className={`px-4 py-2 rounded-lg transition-colors flex items-center gap-1.5 ${yillik ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-slate-50'}`}>
|
<button onClick={() => setYillik(true)} className={`px-4 py-2 rounded-lg transition-colors flex items-center gap-1.5 ${yillik ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-slate-50'}`}>
|
||||||
Yıllık <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${yillik ? 'bg-amber-400 text-slate-900' : 'bg-emerald-100 text-emerald-800'}`}>%17 indirim</span>
|
Yıllık <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${yillik ? 'bg-amber-400 text-slate-900' : 'bg-emerald-100 text-emerald-800'}`}>%17 indirim</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setYillik(false)} className={`px-4 py-2 rounded-lg transition-colors ${!yillik ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-slate-50'}`}>
|
||||||
|
Aylık
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,7 +108,18 @@ export const Pricing = () => {
|
|||||||
{p.fiyat === 0 ? 'Ücretsiz' : `₺${fiyatla(p).toLocaleString('tr-TR')}`}
|
{p.fiyat === 0 ? 'Ücretsiz' : `₺${fiyatla(p).toLocaleString('tr-TR')}`}
|
||||||
</span>
|
</span>
|
||||||
{p.fiyat > 0 && <span className="text-xs text-slate-500 font-medium"> /{yillik ? 'yıl' : 'ay'}</span>}
|
{p.fiyat > 0 && <span className="text-xs text-slate-500 font-medium"> /{yillik ? 'yıl' : 'ay'}</span>}
|
||||||
{p.fiyat > 0 && <p className="text-[11px] text-slate-400 mt-0.5">KDV hariç</p>}
|
{p.fiyat > 0 && (
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
KDV hariç
|
||||||
|
{yillik && (
|
||||||
|
<> · ayda ₺{aylikKarsiligi(p).toLocaleString('tr-TR')} · yılda{' '}
|
||||||
|
<span className="text-emerald-600 font-bold">
|
||||||
|
₺{(p.fiyat * 12 - fiyatla(p)).toLocaleString('tr-TR')} tasarruf
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-2 text-xs text-slate-700 flex-1">
|
<ul className="space-y-2 text-xs text-slate-700 flex-1">
|
||||||
@@ -125,8 +138,8 @@ export const Pricing = () => {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => upgrade(p.id)}
|
onClick={() => upgrade(p.id)}
|
||||||
disabled={!p.satinAlinabilir || busy === p.id || !authUser}
|
disabled={!(yillik ? p.satinAlinabilirYillik : p.satinAlinabilir) || busy === p.id || !authUser}
|
||||||
title={!authUser ? 'Önce giriş yapın' : !p.satinAlinabilir ? 'Ödeme altyapısı bağlanmadı' : ''}
|
title={!authUser ? 'Önce giriş yapın' : !(yillik ? p.satinAlinabilirYillik : p.satinAlinabilir) ? 'Ödeme altyapısı bağlanmadı' : ''}
|
||||||
className={`w-full font-extrabold text-xs py-3 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
className={`w-full font-extrabold text-xs py-3 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||||
p.vurgu ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-slate-900 hover:bg-slate-800 text-white'
|
p.vurgu ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-slate-900 hover:bg-slate-800 text-white'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user