Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ core deploy command must not copy private plugin sources into this repository.

For more details, see [DEPLOY.md](./DEPLOY.md) or [DEPLOY-QUICK.md](./DEPLOY-QUICK.md).

For an API managed by PM2, run `pnpm api:pm2:env:check` on the server before a
release. It compares each running API process with the local `.env` and reports only
drifting variable names. After changing `.env`, use `pnpm api:pm2:reload` to reload
with the file's values, verify all instances, and save the corrected PM2 state.

## Versioning and releases

The deployable core is released as a **single version**: the root `package.json`, `apps/*` and `packages/common` always carry the same number, and an annotated git tag points at it. `@contexthub/promo-sdk` is excluded — it is published separately and keeps its own version.
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contexthub/admin",
"version": "0.1.15",
"version": "0.1.19",
"private": true,
"description": "React administration interface for contextHub",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/components/Footer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function Footer({
const currentYear = new Date().getFullYear()

return (
<footer className="bg-white border-t border-gray-200">
<footer className="shrink-0 bg-white border-t border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 items-center gap-3 py-4 lg:grid-cols-[auto_1fr_auto]">
{/* Dil seçici - sol */}
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ export default function Layout() {
? <Navigate to="/faturalandirma" replace /> : <Outlet />}
</div>
</main>
<Footer authenticated />
{!isContentEditorRoute && <Footer authenticated />}
</div>
</div>
</div>
Expand Down
6 changes: 4 additions & 2 deletions apps/admin/src/lib/api/billing.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { apiClient } from '../api.js'

export async function fetchBillingOverview() {
const response = await apiClient.get('/billing/overview')
export async function fetchBillingOverview({ previewCountry = '', previewPlanSlug = '', previewInterval = 'month' } = {}) {
const response = await apiClient.get('/billing/overview', {
params: { previewCountry, previewPlanSlug, previewInterval },
})
return response.data
}

Expand Down
7 changes: 5 additions & 2 deletions apps/admin/src/locales/en/content.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@
"content.refreshing": "Refreshing",
"content.replace": "Replace",
"content.save_failed": "Saving failed. Please try again.",
"content.save_panel_drag": "Drag the save panel. Use arrow keys to move it; press Home or Escape to return it to its default position.",
"content.save_panel_label": "Content save panel",
"content.scheduled_publish_date": "Scheduled Publish Date",
"content.scheduling_disabled": "Content scheduling is disabled for this tenant.",
"content.scheduling_disabled_existing": "Scheduling is disabled for this tenant, but this content is currently scheduled. Once you change the status you will not be able to schedule it again.",
Expand All @@ -121,8 +123,9 @@
"content.slug_taken_for_title": "\"{{slug}}\" is already in use. Please choose a different title.",
"content.source_html_current": "Edit in HTML format",
"content.source_html_switch": "Switch to HTML format",
"content.source_json_current": "Edit in JSON format",
"content.source_json_switch": "Switch to JSON format",
"content.source_block_current": "Edit in block view",
"content.source_block_switch": "Switch to block view",
"content.source_block_label": "Block",
"content.source_label": "Source:",
"content.status_unknown": "Unknown",
"content.summary": "Summary",
Expand Down
7 changes: 5 additions & 2 deletions apps/admin/src/locales/tr/content.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@
"content.refreshing": "Yenileniyor",
"content.replace": "Değiştir",
"content.save_failed": "Kaydetme işlemi başarısız oldu. Lütfen tekrar deneyin.",
"content.save_panel_drag": "Kayıt panelini sürükle. Ok tuşlarıyla taşıyın; başlangıç konumuna dönmek için Home veya Escape tuşuna basın.",
"content.save_panel_label": "İçerik kayıt paneli",
"content.scheduled_publish_date": "Planlanan Yayın Tarihi",
"content.scheduling_disabled": "Bu varlık için içerik zamanlama özelliği kapalı.",
"content.scheduling_disabled_existing": "Zamanlama bu varlık için kapalı; mevcut içerik zamanlanmış durumda. Durumu değiştirdiğinizde yeniden zamanlayamazsınız.",
Expand All @@ -121,8 +123,9 @@
"content.slug_taken_for_title": "\"{{slug}}\" zaten kullanılıyor. Lütfen farklı bir başlık seçin.",
"content.source_html_current": "HTML biçiminde düzenle",
"content.source_html_switch": "HTML biçimine geç",
"content.source_json_current": "JSON biçiminde düzenle",
"content.source_json_switch": "JSON biçimine geç",
"content.source_block_current": "Blok görünümünde düzenle",
"content.source_block_switch": "Blok görünümüne geç",
"content.source_block_label": "Blok",
"content.source_label": "Kaynak:",
"content.status_unknown": "Bilinmiyor",
"content.summary": "Özet",
Expand Down
46 changes: 34 additions & 12 deletions apps/admin/src/pages/billing/Billing.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Expand Down Expand Up @@ -118,8 +118,19 @@ export default function Billing() {
const [profile, setProfile] = useState(EMPTY_PROFILE)
const [fieldErrors, setFieldErrors] = useState({})
const [hostedPaymentContent, setHostedPaymentContent] = useState('')
const profileInitializedForTenant = useRef('')
const scrolledToPlanForTenant = useRef('')
const locale = i18n.resolvedLanguage === 'en' ? 'en-US' : 'tr-TR'
const overview = useQuery({ queryKey: ['billing', 'overview', activeTenantId], queryFn: fetchBillingOverview, retry: 1, enabled: canView, refetchInterval: (data) => data?.tenant?.status === 'pending_payment' ? 5000 : false })
const previewCountry = profile.country
const overview = useQuery({
queryKey: ['billing', 'overview', activeTenantId, previewCountry, checkoutIntent.planSlug, interval],
queryFn: () => fetchBillingOverview({ previewCountry, previewPlanSlug: checkoutIntent.planSlug, previewInterval: interval }),
retry: 1,
enabled: canView,
keepPreviousData: true,
staleTime: 60_000,
refetchInterval: false,
})

useEffect(() => {
if (overview.data?.tenant?.status === 'active' && activeMembership?.tenant?.status === 'pending_payment') {
Expand Down Expand Up @@ -151,25 +162,31 @@ export default function Billing() {
}, [t, toast])

useEffect(() => {
if (!activeTenantId || overview.data?.tenant?.id !== activeTenantId || profileInitializedForTenant.current === activeTenantId) return
profileInitializedForTenant.current = activeTenantId
const saved = overview.data?.billingAccount
if (!saved) return
setProfile({
...EMPTY_PROFILE,
...saved,
...(saved || {}),
taxId: '',
address: { ...EMPTY_PROFILE.address, ...(saved.address || {}) },
address: { ...EMPTY_PROFILE.address, ...(saved?.address || {}) },
declarationAccepted: false,
serviceAgreementAccepted: false,
})
}, [overview.data?.billingAccount])
}, [activeTenantId, overview.data?.tenant?.id, overview.data?.billingAccount])

useEffect(() => {
if (!overview.data || !checkoutIntent.planSlug) return
document.getElementById(`billing-plan-${checkoutIntent.planSlug}`)?.scrollIntoView({
if (!overview.data?.plans?.length || !checkoutIntent.planSlug || !activeTenantId) return
const scrollKey = `${activeTenantId}:${checkoutIntent.planSlug}`
if (scrolledToPlanForTenant.current === scrollKey) return
const planElement = document.getElementById(`billing-plan-${checkoutIntent.planSlug}`)
if (!planElement) return
scrolledToPlanForTenant.current = scrollKey
planElement.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'center',
})
}, [checkoutIntent.planSlug, overview.data])
}, [activeTenantId, checkoutIntent.planSlug, overview.data?.plans])

const checkout = useMutation({
mutationFn: createBillingCheckout,
Expand Down Expand Up @@ -247,7 +264,7 @@ export default function Billing() {

return (
<main style={TOKENS} className="min-h-[calc(100vh-4rem)] bg-[var(--billing-canvas)] text-[var(--billing-ink)]">
<HostedPaymentFrame content={hostedPaymentContent} onClose={() => setHostedPaymentContent('')} t={t} />
<HostedPaymentFrame content={hostedPaymentContent} onClose={() => { setHostedPaymentContent(''); overview.refetch() }} t={t} />
<div className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<header className="flex flex-col gap-4 border-b border-[var(--billing-line)] pb-6 sm:flex-row sm:items-end sm:justify-between">
<div>
Expand Down Expand Up @@ -278,6 +295,11 @@ export default function Billing() {
<div className="p-6 sm:p-8">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-[var(--billing-muted)]">{t('billing.active.eyebrow')}</p>
<div className="mt-3 flex flex-wrap items-baseline gap-3"><h2 className="text-4xl font-semibold">{overview.data.tenant.status === 'pending_payment' ? t('tenant.payment_pending') : overview.data.tenant.plan.name}</h2><span className="rounded-full bg-[var(--billing-accent-soft)] px-3 py-1 text-xs font-bold text-[var(--billing-accent)]">{overview.data.tenant.status === 'pending_payment' ? overview.data.plans?.find((plan) => plan.slug === overview.data.tenant.requestedPlanSlug)?.name : activePlanStatus(t, overview.data.tenant.plan, overview.data.subscription)}</span></div>
{overview.data.tenant.status === 'pending_payment' && (
<button type="button" onClick={() => overview.refetch()} disabled={!online || overview.isFetching} className="mt-4 inline-flex items-center gap-2 rounded-lg border border-[var(--billing-line)] px-3 py-2 text-sm font-semibold disabled:opacity-50">
<ArrowPathIcon className="h-4 w-4" /> {t('common.refresh')}
</button>
)}
<p className="mt-5 text-sm text-[var(--billing-muted)]">{t('billing.active.accountLine', { tenant: overview.data.tenant.name, account: overview.data.account.name })}</p>
</div>
<div className="border-t border-[var(--billing-line)] bg-[var(--billing-accent)] p-6 text-white lg:border-l lg:border-t-0 sm:p-8">
Expand Down Expand Up @@ -448,7 +470,7 @@ export default function Billing() {
</div>
</div>
<p className="mt-3 max-w-3xl text-xs leading-5 text-[var(--billing-muted)]">
{t(overview.data.billingAccount?.country === 'TR'
{t((previewCountry || overview.data.billingAccount?.country) === 'TR'
? 'billing.plans.tryCatalogNote'
: 'billing.plans.usdCatalogNote')}
</p>
Expand All @@ -461,7 +483,7 @@ export default function Billing() {
const requested = overview.data.tenant.status === 'pending_payment' && overview.data.tenant.requestedPlanSlug === plan.slug
const highlighted = checkoutIntent.planSlug === plan.slug
const hasSubscription = Boolean(overview.data.subscription && ['active', 'trialing', 'past_due', 'paused'].includes(overview.data.subscription.status))
const checkoutAvailable = Boolean(overview.data.paymentRouting?.checkoutAvailable)
const checkoutAvailable = Boolean(overview.data.paymentRouting?.checkoutAvailable && profile.country === overview.data.billingAccount?.country)
const canCheckout = !enterprise && !current && !hasSubscription && canManage && online && checkoutAvailable && price?.checkoutReady && price?.id
const canOpenProfile = !enterprise && !current && !hasSubscription && canManage && online && !overview.data.paymentRouting?.profileComplete
const buttonLabel = checkoutButtonLabel(t, { current, enterprise, checkoutAvailable, checkoutReady: price?.checkoutReady, hasProfile: overview.data.paymentRouting?.profileComplete, hasSubscription })
Expand Down
108 changes: 108 additions & 0 deletions apps/admin/src/pages/billing/BillingFlow.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useQuery } from '@tanstack/react-query'
import Billing from './Billing.jsx'

vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key) => key, i18n: { resolvedLanguage: 'tr' } }),
}))
vi.mock('../../contexts/AuthContext.jsx', () => ({
useAuth: () => ({ hasPermission: () => true, activeTenantId: 'tenant-1', activeMembership: null }),
}))
vi.mock('../../contexts/ToastContext.jsx', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn() }),
}))
vi.mock('../../components/CountryCombobox.jsx', () => ({
default: ({ value, onChange }) => <select aria-label="Country" value={value} onChange={(event) => onChange(event.target.value)}><option value="" /><option value="TR">TR</option></select>,
}))

function overview() {
return {
tenant: { id: 'tenant-1', name: 'Canary', status: 'pending_payment', requestedPlanSlug: 'pro', plan: { slug: 'free', name: 'Free' } },
account: { name: 'Canary' },
billingAccount: { legalName: '', country: '', address: {} },
paymentRouting: { profileComplete: false, checkoutAvailable: false },
subscription: null,
plans: [{ id: 'pro-id', slug: 'pro', name: 'Pro', prices: [{ interval: 'month', amountMinor: 49900, currency: 'TRY', catalogOnly: true }], capabilities: [] }],
charges: { subscription: { amountMinor: 49900, currency: 'TRY', interval: 'month', isEstimated: true }, usageEstimate: { available: false, lines: [] }, latestInvoice: null },
quotaAlerts: [], usage: {}, invoices: [],
}
}

describe('billing checkout intent', () => {
let root
let container
let queryData
let scrollIntoView
let refetch

beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
window.history.pushState({}, '', '/faturalandirma?plan=pro&interval=month')
window.matchMedia = vi.fn(() => ({ matches: true }))
scrollIntoView = vi.fn()
refetch = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
queryData = overview()
useQuery.mockImplementation(({ queryKey }) => {
const tr = queryKey[3] === 'TR'
const data = {
...queryData,
plans: [{ ...queryData.plans[0], prices: [{ ...queryData.plans[0].prices[0], amountMinor: tr ? 49900 : 1200, currency: tr ? 'TRY' : 'USD' }] }],
charges: { ...queryData.charges, subscription: { ...queryData.charges.subscription, amountMinor: tr ? 49900 : 1200, currency: tr ? 'TRY' : 'USD' } },
}
return { data, isLoading: false, isError: false, refetch }
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(async () => {
await act(async () => root.unmount())
container.remove()
window.history.replaceState({}, '', '/')
delete Element.prototype.scrollIntoView
delete globalThis.IS_REACT_ACT_ENVIRONMENT
vi.clearAllMocks()
})

it('shows USD until TR is selected and keeps the form usable through overview refreshes', async () => {
await act(async () => root.render(<Billing />))

expect(useQuery.mock.calls.at(-1)[0].queryKey).toContain('')
expect(useQuery.mock.calls.at(-1)[0].refetchInterval).toBe(false)
expect(container.textContent).toContain('$12')
expect(scrollIntoView).toHaveBeenCalledTimes(1)

const country = container.querySelector('select[aria-label="Country"]')
await act(async () => {
country.value = 'TR'
country.dispatchEvent(new Event('change', { bubbles: true }))
})
expect(useQuery.mock.calls.at(-1)[0].queryKey).toContain('TR')
expect(container.textContent).toContain('₺499')
expect(container.textContent).toContain('common.refresh')
const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent.includes('common.refresh'))
await act(async () => refreshButton.click())
expect(refetch).toHaveBeenCalledTimes(1)

const nameInput = container.querySelector('input[autocomplete="organization"]')
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(nameInput, 'Canary Ltd')
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(nameInput.value).toBe('Canary Ltd')

queryData = overview()
await act(async () => root.render(<Billing />))

expect(nameInput.value).toBe('Canary Ltd')
expect(scrollIntoView).toHaveBeenCalledTimes(1)
})
})
Loading
Loading