@autumnsgrove/groveengine 0.4.12 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/quota/QuotaWarning.svelte +125 -0
- package/dist/components/quota/QuotaWarning.svelte.d.ts +16 -0
- package/dist/components/quota/QuotaWidget.svelte +120 -0
- package/dist/components/quota/QuotaWidget.svelte.d.ts +15 -0
- package/dist/components/quota/UpgradePrompt.svelte +287 -0
- package/dist/components/quota/UpgradePrompt.svelte.d.ts +13 -0
- package/dist/components/quota/index.d.ts +8 -0
- package/dist/components/quota/index.js +8 -0
- package/dist/groveauth/client.d.ts +143 -0
- package/dist/groveauth/client.js +502 -0
- package/dist/groveauth/colors.d.ts +35 -0
- package/dist/groveauth/colors.js +91 -0
- package/dist/groveauth/index.d.ts +34 -0
- package/dist/groveauth/index.js +35 -0
- package/dist/groveauth/limits.d.ts +70 -0
- package/dist/groveauth/limits.js +194 -0
- package/dist/groveauth/rate-limit.d.ts +95 -0
- package/dist/groveauth/rate-limit.js +172 -0
- package/dist/groveauth/types.d.ts +137 -0
- package/dist/groveauth/types.js +57 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* QuotaWarning - Warning banner for post limit status
|
|
4
|
+
*
|
|
5
|
+
* Shows contextual warnings when users are near or at their limit.
|
|
6
|
+
* Can be placed on the post editor or dashboard.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PreSubmitCheckResult, AlertVariant } from '../../groveauth/index.js';
|
|
10
|
+
import { ALERT_VARIANTS } from '../../groveauth/index.js';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
check: PreSubmitCheckResult;
|
|
14
|
+
upgradeUrl?: string;
|
|
15
|
+
onDismiss?: () => void;
|
|
16
|
+
showDismiss?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
check,
|
|
21
|
+
upgradeUrl = '/upgrade',
|
|
22
|
+
onDismiss,
|
|
23
|
+
showDismiss = true,
|
|
24
|
+
}: Props = $props();
|
|
25
|
+
|
|
26
|
+
let dismissed = $state(false);
|
|
27
|
+
|
|
28
|
+
function handleDismiss() {
|
|
29
|
+
dismissed = true;
|
|
30
|
+
onDismiss?.();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Defensive check for malformed data
|
|
34
|
+
const isValidCheck = $derived(
|
|
35
|
+
check &&
|
|
36
|
+
typeof check === 'object' &&
|
|
37
|
+
check.status &&
|
|
38
|
+
typeof check.status === 'object'
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Determine variant based on check result
|
|
42
|
+
const variant = $derived<AlertVariant>(
|
|
43
|
+
!isValidCheck ? 'info' :
|
|
44
|
+
check.upgradeRequired ? 'error' :
|
|
45
|
+
check.status.is_in_grace_period ? 'warning' :
|
|
46
|
+
check.status.is_at_limit ? 'warning' :
|
|
47
|
+
'info'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Get variant classes from shared utility
|
|
51
|
+
const variantClasses = $derived(ALERT_VARIANTS[variant]);
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
{#if isValidCheck && check.showWarning && !dismissed}
|
|
55
|
+
<div class="rounded-lg border p-4 {variantClasses.container}">
|
|
56
|
+
<div class="flex items-start gap-3">
|
|
57
|
+
<!-- Icon -->
|
|
58
|
+
<div class="flex-shrink-0">
|
|
59
|
+
{#if variant === 'error'}
|
|
60
|
+
<svg class="w-5 h-5 {variantClasses.icon}" fill="currentColor" viewBox="0 0 20 20">
|
|
61
|
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
|
62
|
+
</svg>
|
|
63
|
+
{:else if variant === 'warning'}
|
|
64
|
+
<svg class="w-5 h-5 {variantClasses.icon}" fill="currentColor" viewBox="0 0 20 20">
|
|
65
|
+
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
66
|
+
</svg>
|
|
67
|
+
{:else}
|
|
68
|
+
<svg class="w-5 h-5 {variantClasses.icon}" fill="currentColor" viewBox="0 0 20 20">
|
|
69
|
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
|
70
|
+
</svg>
|
|
71
|
+
{/if}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<!-- Content -->
|
|
75
|
+
<div class="flex-1">
|
|
76
|
+
<h3 class="text-sm font-medium {variantClasses.title}">
|
|
77
|
+
{#if check.upgradeRequired}
|
|
78
|
+
Upgrade Required
|
|
79
|
+
{:else if check.status.is_in_grace_period}
|
|
80
|
+
Grace Period Active
|
|
81
|
+
{:else if check.status.is_at_limit}
|
|
82
|
+
Post Limit Reached
|
|
83
|
+
{:else}
|
|
84
|
+
Approaching Limit
|
|
85
|
+
{/if}
|
|
86
|
+
</h3>
|
|
87
|
+
|
|
88
|
+
{#if check.warningMessage}
|
|
89
|
+
<p class="mt-1 text-sm {variantClasses.text}">
|
|
90
|
+
{check.warningMessage}
|
|
91
|
+
</p>
|
|
92
|
+
{/if}
|
|
93
|
+
|
|
94
|
+
<!-- Actions -->
|
|
95
|
+
<div class="mt-3 flex items-center gap-3">
|
|
96
|
+
<a
|
|
97
|
+
href={upgradeUrl}
|
|
98
|
+
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md {variantClasses.button}"
|
|
99
|
+
>
|
|
100
|
+
{check.upgradeRequired ? 'Upgrade Now' : 'View Plans'}
|
|
101
|
+
</a>
|
|
102
|
+
|
|
103
|
+
{#if !check.upgradeRequired}
|
|
104
|
+
<span class="text-sm {variantClasses.text}">
|
|
105
|
+
{check.status.posts_remaining !== null ? `${check.status.posts_remaining} posts remaining` : ''}
|
|
106
|
+
</span>
|
|
107
|
+
{/if}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- Dismiss button -->
|
|
112
|
+
{#if showDismiss && !check.upgradeRequired}
|
|
113
|
+
<button
|
|
114
|
+
onclick={handleDismiss}
|
|
115
|
+
class="flex-shrink-0 {variantClasses.text} hover:opacity-75"
|
|
116
|
+
aria-label="Dismiss"
|
|
117
|
+
>
|
|
118
|
+
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
119
|
+
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
120
|
+
</svg>
|
|
121
|
+
</button>
|
|
122
|
+
{/if}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QuotaWarning - Warning banner for post limit status
|
|
3
|
+
*
|
|
4
|
+
* Shows contextual warnings when users are near or at their limit.
|
|
5
|
+
* Can be placed on the post editor or dashboard.
|
|
6
|
+
*/
|
|
7
|
+
import type { PreSubmitCheckResult } from '../../groveauth/index.js';
|
|
8
|
+
interface Props {
|
|
9
|
+
check: PreSubmitCheckResult;
|
|
10
|
+
upgradeUrl?: string;
|
|
11
|
+
onDismiss?: () => void;
|
|
12
|
+
showDismiss?: boolean;
|
|
13
|
+
}
|
|
14
|
+
declare const QuotaWarning: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type QuotaWarning = ReturnType<typeof QuotaWarning>;
|
|
16
|
+
export default QuotaWarning;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* QuotaWidget - Displays post usage quota
|
|
4
|
+
*
|
|
5
|
+
* Shows current post count, limit, and visual progress bar.
|
|
6
|
+
* Includes upgrade prompts when near or at limit.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { QuotaWidgetData } from '../../groveauth/index.js';
|
|
10
|
+
import { STATUS_COLORS } from '../../groveauth/index.js';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
data: QuotaWidgetData;
|
|
14
|
+
upgradeUrl?: string;
|
|
15
|
+
compact?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { data, upgradeUrl = '/upgrade', compact = false }: Props = $props();
|
|
19
|
+
|
|
20
|
+
// Defensive check for malformed data
|
|
21
|
+
const isValidData = $derived(
|
|
22
|
+
data &&
|
|
23
|
+
typeof data === 'object' &&
|
|
24
|
+
typeof data.count === 'number' &&
|
|
25
|
+
typeof data.color === 'string'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Default fallback data for when data is invalid
|
|
29
|
+
const safeData = $derived(isValidData ? data : {
|
|
30
|
+
count: 0,
|
|
31
|
+
limit: null,
|
|
32
|
+
percentage: null,
|
|
33
|
+
remaining: null,
|
|
34
|
+
color: 'gray' as const,
|
|
35
|
+
statusText: 'Loading...',
|
|
36
|
+
description: 'Unable to load quota information',
|
|
37
|
+
showUpgrade: false,
|
|
38
|
+
tierName: 'Unknown',
|
|
39
|
+
canPost: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Get color classes from shared utility
|
|
43
|
+
const colorClasses = $derived(STATUS_COLORS[safeData.color]);
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
{#if compact}
|
|
47
|
+
<!-- Compact mode: just the numbers -->
|
|
48
|
+
<div class="flex items-center gap-2 text-sm">
|
|
49
|
+
<span class="font-medium">
|
|
50
|
+
{safeData.count}{#if safeData.limit !== null}<span class="text-gray-400">/{safeData.limit}</span>{/if}
|
|
51
|
+
</span>
|
|
52
|
+
<span class="px-1.5 py-0.5 text-xs rounded {colorClasses.badge}">
|
|
53
|
+
{safeData.statusText}
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
{:else}
|
|
57
|
+
<!-- Full widget -->
|
|
58
|
+
<div class="rounded-lg border border-gray-200 dark:border-gray-700 p-4 {colorClasses.bg}">
|
|
59
|
+
<div class="flex justify-between items-center mb-2">
|
|
60
|
+
<h4 class="font-semibold text-gray-900 dark:text-gray-100">Post Usage</h4>
|
|
61
|
+
<span class="px-2 py-1 text-xs font-medium rounded-full {colorClasses.badge}">
|
|
62
|
+
{safeData.statusText}
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- Count display -->
|
|
67
|
+
<div class="text-2xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
|
68
|
+
{safeData.count}{#if safeData.limit !== null}<span class="text-gray-400 dark:text-gray-500">/{safeData.limit}</span>{/if}
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<!-- Progress bar -->
|
|
72
|
+
{#if safeData.limit !== null && safeData.percentage !== null}
|
|
73
|
+
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
|
|
74
|
+
<div
|
|
75
|
+
class="h-full rounded-full transition-all duration-300 {colorClasses.fill}"
|
|
76
|
+
style="width: {Math.min(safeData.percentage, 100)}%"
|
|
77
|
+
></div>
|
|
78
|
+
</div>
|
|
79
|
+
<p class="text-sm {colorClasses.text}">{safeData.percentage.toFixed(1)}% used</p>
|
|
80
|
+
{:else}
|
|
81
|
+
<p class="text-sm text-gray-500 dark:text-gray-400">Unlimited posts with {safeData.tierName} plan</p>
|
|
82
|
+
{/if}
|
|
83
|
+
|
|
84
|
+
<!-- Upgrade prompt -->
|
|
85
|
+
{#if safeData.showUpgrade}
|
|
86
|
+
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
|
87
|
+
<p class="text-sm text-blue-800 dark:text-blue-200 mb-2">
|
|
88
|
+
Upgrade for more posts
|
|
89
|
+
</p>
|
|
90
|
+
<a
|
|
91
|
+
href={upgradeUrl}
|
|
92
|
+
class="inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
|
93
|
+
>
|
|
94
|
+
View Plans
|
|
95
|
+
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
96
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
97
|
+
</svg>
|
|
98
|
+
</a>
|
|
99
|
+
</div>
|
|
100
|
+
{/if}
|
|
101
|
+
|
|
102
|
+
<!-- Cannot post warning -->
|
|
103
|
+
{#if !safeData.canPost}
|
|
104
|
+
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/30 rounded-lg border border-red-200 dark:border-red-800">
|
|
105
|
+
<p class="text-sm text-red-800 dark:text-red-200 font-medium">
|
|
106
|
+
You cannot create new posts until you upgrade or delete existing posts.
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
{/if}
|
|
110
|
+
|
|
111
|
+
<!-- Error state for invalid data -->
|
|
112
|
+
{#if !isValidData}
|
|
113
|
+
<div class="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
114
|
+
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
115
|
+
Unable to load quota information. Please refresh the page.
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
{/if}
|
|
119
|
+
</div>
|
|
120
|
+
{/if}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QuotaWidget - Displays post usage quota
|
|
3
|
+
*
|
|
4
|
+
* Shows current post count, limit, and visual progress bar.
|
|
5
|
+
* Includes upgrade prompts when near or at limit.
|
|
6
|
+
*/
|
|
7
|
+
import type { QuotaWidgetData } from '../../groveauth/index.js';
|
|
8
|
+
interface Props {
|
|
9
|
+
data: QuotaWidgetData;
|
|
10
|
+
upgradeUrl?: string;
|
|
11
|
+
compact?: boolean;
|
|
12
|
+
}
|
|
13
|
+
declare const QuotaWidget: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type QuotaWidget = ReturnType<typeof QuotaWidget>;
|
|
15
|
+
export default QuotaWidget;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* UpgradePrompt - Modal/dialog for upgrade prompts
|
|
4
|
+
*
|
|
5
|
+
* Shows when user tries to create a post but is at/over limit.
|
|
6
|
+
* Provides options to upgrade, delete posts, or cancel.
|
|
7
|
+
*
|
|
8
|
+
* Accessibility features:
|
|
9
|
+
* - Focus trap within modal
|
|
10
|
+
* - Escape key closes modal
|
|
11
|
+
* - Focus returns to trigger element on close
|
|
12
|
+
* - ARIA attributes for screen readers
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { onMount } from 'svelte';
|
|
16
|
+
import type { SubscriptionStatus } from '../../groveauth/index.js';
|
|
17
|
+
import { TIER_NAMES } from '../../groveauth/index.js';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
open: boolean;
|
|
21
|
+
status: SubscriptionStatus;
|
|
22
|
+
upgradeUrl?: string;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
onProceed?: () => void; // If allowed during grace period
|
|
25
|
+
oldestPostTitle?: string;
|
|
26
|
+
oldestPostDate?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
open,
|
|
31
|
+
status,
|
|
32
|
+
upgradeUrl = '/upgrade',
|
|
33
|
+
onClose,
|
|
34
|
+
onProceed,
|
|
35
|
+
oldestPostTitle,
|
|
36
|
+
oldestPostDate,
|
|
37
|
+
}: Props = $props();
|
|
38
|
+
|
|
39
|
+
// Reference to the modal dialog element
|
|
40
|
+
let dialogRef: HTMLDivElement | null = $state(null);
|
|
41
|
+
|
|
42
|
+
// Store the previously focused element to restore on close
|
|
43
|
+
let previouslyFocusedElement: HTMLElement | null = null;
|
|
44
|
+
|
|
45
|
+
// Defensive check for malformed status data
|
|
46
|
+
const isValidStatus = $derived(
|
|
47
|
+
status &&
|
|
48
|
+
typeof status === 'object' &&
|
|
49
|
+
typeof status.tier === 'string' &&
|
|
50
|
+
typeof status.post_count === 'number'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Safe status with fallback values
|
|
54
|
+
const safeStatus = $derived(isValidStatus ? status : {
|
|
55
|
+
tier: 'starter' as const,
|
|
56
|
+
post_count: 0,
|
|
57
|
+
post_limit: 250,
|
|
58
|
+
posts_remaining: 250,
|
|
59
|
+
percentage_used: 0,
|
|
60
|
+
is_at_limit: false,
|
|
61
|
+
is_in_grace_period: false,
|
|
62
|
+
grace_period_days_remaining: null,
|
|
63
|
+
can_create_post: true,
|
|
64
|
+
upgrade_required: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Can proceed if in grace period and not expired
|
|
68
|
+
const canProceed = $derived(
|
|
69
|
+
safeStatus.is_in_grace_period &&
|
|
70
|
+
safeStatus.grace_period_days_remaining !== null &&
|
|
71
|
+
safeStatus.grace_period_days_remaining > 0
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Tier upgrade path
|
|
75
|
+
const nextTier = $derived(
|
|
76
|
+
safeStatus.tier === 'starter' ? 'professional' :
|
|
77
|
+
safeStatus.tier === 'professional' ? 'business' :
|
|
78
|
+
null
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const nextTierName = $derived(nextTier ? TIER_NAMES[nextTier] : null);
|
|
82
|
+
const currentTierName = $derived(TIER_NAMES[safeStatus.tier] || 'Unknown');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get all focusable elements within the modal
|
|
86
|
+
*/
|
|
87
|
+
function getFocusableElements(): HTMLElement[] {
|
|
88
|
+
if (!dialogRef) return [];
|
|
89
|
+
return Array.from(
|
|
90
|
+
dialogRef.querySelectorAll<HTMLElement>(
|
|
91
|
+
'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handle keyboard events for focus trap and escape
|
|
98
|
+
*/
|
|
99
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
100
|
+
if (!open) return;
|
|
101
|
+
|
|
102
|
+
if (e.key === 'Escape') {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
onClose();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (e.key === 'Tab') {
|
|
109
|
+
const focusableElements = getFocusableElements();
|
|
110
|
+
if (focusableElements.length === 0) return;
|
|
111
|
+
|
|
112
|
+
const firstElement = focusableElements[0];
|
|
113
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
114
|
+
|
|
115
|
+
if (e.shiftKey) {
|
|
116
|
+
// Shift+Tab: if on first element, go to last
|
|
117
|
+
if (document.activeElement === firstElement) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
lastElement.focus();
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// Tab: if on last element, go to first
|
|
123
|
+
if (document.activeElement === lastElement) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
firstElement.focus();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Focus the first focusable element when modal opens
|
|
133
|
+
*/
|
|
134
|
+
function focusFirstElement() {
|
|
135
|
+
const focusableElements = getFocusableElements();
|
|
136
|
+
if (focusableElements.length > 0) {
|
|
137
|
+
focusableElements[0].focus();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle modal open/close
|
|
142
|
+
$effect(() => {
|
|
143
|
+
if (open) {
|
|
144
|
+
// Store the currently focused element
|
|
145
|
+
previouslyFocusedElement = document.activeElement as HTMLElement;
|
|
146
|
+
|
|
147
|
+
// Add escape key listener to document
|
|
148
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
149
|
+
|
|
150
|
+
// Focus the first element after render
|
|
151
|
+
// Use setTimeout to ensure the DOM is ready
|
|
152
|
+
setTimeout(focusFirstElement, 0);
|
|
153
|
+
} else {
|
|
154
|
+
// Remove event listener
|
|
155
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
156
|
+
|
|
157
|
+
// Restore focus to the previously focused element
|
|
158
|
+
if (previouslyFocusedElement) {
|
|
159
|
+
previouslyFocusedElement.focus();
|
|
160
|
+
previouslyFocusedElement = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Cleanup on unmount
|
|
166
|
+
onMount(() => {
|
|
167
|
+
return () => {
|
|
168
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
{#if open}
|
|
174
|
+
<!-- Backdrop -->
|
|
175
|
+
<div
|
|
176
|
+
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
|
177
|
+
onclick={onClose}
|
|
178
|
+
role="presentation"
|
|
179
|
+
>
|
|
180
|
+
<!-- Modal -->
|
|
181
|
+
<div
|
|
182
|
+
bind:this={dialogRef}
|
|
183
|
+
class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-md w-full p-6"
|
|
184
|
+
onclick={(e) => e.stopPropagation()}
|
|
185
|
+
role="dialog"
|
|
186
|
+
aria-modal="true"
|
|
187
|
+
aria-labelledby="upgrade-title"
|
|
188
|
+
aria-describedby="upgrade-description"
|
|
189
|
+
>
|
|
190
|
+
<!-- Header -->
|
|
191
|
+
<div class="text-center mb-6">
|
|
192
|
+
<div class="mx-auto w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 rounded-full flex items-center justify-center mb-4">
|
|
193
|
+
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
194
|
+
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
195
|
+
</svg>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<h3 id="upgrade-title" class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
199
|
+
{#if safeStatus.upgrade_required}
|
|
200
|
+
Upgrade Required
|
|
201
|
+
{:else}
|
|
202
|
+
You're at {safeStatus.post_count}/{safeStatus.post_limit} posts
|
|
203
|
+
{/if}
|
|
204
|
+
</h3>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<!-- Content -->
|
|
208
|
+
<div id="upgrade-description" class="space-y-4 mb-6">
|
|
209
|
+
{#if safeStatus.upgrade_required}
|
|
210
|
+
<p class="text-sm text-gray-600 dark:text-gray-400 text-center">
|
|
211
|
+
Your grace period has expired. To continue creating posts, please upgrade your plan or delete some existing posts.
|
|
212
|
+
</p>
|
|
213
|
+
{:else if safeStatus.is_in_grace_period}
|
|
214
|
+
<p class="text-sm text-gray-600 dark:text-gray-400 text-center">
|
|
215
|
+
You're over your post limit. You have <strong class="text-yellow-600 dark:text-yellow-400">{safeStatus.grace_period_days_remaining} days</strong> remaining in your grace period.
|
|
216
|
+
</p>
|
|
217
|
+
|
|
218
|
+
{#if oldestPostTitle}
|
|
219
|
+
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 text-sm">
|
|
220
|
+
<p class="text-gray-500 dark:text-gray-400">If you continue, new posts may need to replace older ones. Your oldest post:</p>
|
|
221
|
+
<p class="mt-1 font-medium text-gray-900 dark:text-gray-100">"{oldestPostTitle}"</p>
|
|
222
|
+
{#if oldestPostDate}
|
|
223
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">{oldestPostDate}</p>
|
|
224
|
+
{/if}
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
227
|
+
{:else}
|
|
228
|
+
<p class="text-sm text-gray-600 dark:text-gray-400 text-center">
|
|
229
|
+
You've reached your post limit on the <strong>{currentTierName}</strong> plan. Upgrade to get more posts.
|
|
230
|
+
</p>
|
|
231
|
+
{/if}
|
|
232
|
+
|
|
233
|
+
<!-- Tier comparison -->
|
|
234
|
+
{#if nextTierName}
|
|
235
|
+
<div class="bg-blue-50 dark:bg-blue-900/30 rounded-lg p-4">
|
|
236
|
+
<div class="flex items-center justify-between">
|
|
237
|
+
<div>
|
|
238
|
+
<p class="font-medium text-blue-900 dark:text-blue-100">{nextTierName} Plan</p>
|
|
239
|
+
<p class="text-sm text-blue-700 dark:text-blue-300">
|
|
240
|
+
{#if nextTier === 'professional'}
|
|
241
|
+
Up to 2,000 posts
|
|
242
|
+
{:else}
|
|
243
|
+
Unlimited posts
|
|
244
|
+
{/if}
|
|
245
|
+
</p>
|
|
246
|
+
</div>
|
|
247
|
+
<a
|
|
248
|
+
href={upgradeUrl}
|
|
249
|
+
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
|
|
250
|
+
>
|
|
251
|
+
Upgrade
|
|
252
|
+
</a>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
{/if}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<!-- Actions -->
|
|
259
|
+
<div class="flex flex-col gap-3">
|
|
260
|
+
{#if canProceed && onProceed}
|
|
261
|
+
<button
|
|
262
|
+
onclick={onProceed}
|
|
263
|
+
class="w-full px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
|
|
264
|
+
>
|
|
265
|
+
Continue Anyway
|
|
266
|
+
</button>
|
|
267
|
+
{/if}
|
|
268
|
+
|
|
269
|
+
<div class="flex gap-3">
|
|
270
|
+
<a
|
|
271
|
+
href="/admin/posts"
|
|
272
|
+
class="flex-1 px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 text-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
|
|
273
|
+
>
|
|
274
|
+
Manage Posts
|
|
275
|
+
</a>
|
|
276
|
+
|
|
277
|
+
<button
|
|
278
|
+
onclick={onClose}
|
|
279
|
+
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
|
|
280
|
+
>
|
|
281
|
+
Cancel
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
{/if}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SubscriptionStatus } from '../../groveauth/index.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
open: boolean;
|
|
4
|
+
status: SubscriptionStatus;
|
|
5
|
+
upgradeUrl?: string;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
onProceed?: () => void;
|
|
8
|
+
oldestPostTitle?: string;
|
|
9
|
+
oldestPostDate?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const UpgradePrompt: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type UpgradePrompt = ReturnType<typeof UpgradePrompt>;
|
|
13
|
+
export default UpgradePrompt;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Components
|
|
3
|
+
*
|
|
4
|
+
* UI components for displaying and managing post quotas.
|
|
5
|
+
*/
|
|
6
|
+
export { default as QuotaWidget } from './QuotaWidget.svelte';
|
|
7
|
+
export { default as QuotaWarning } from './QuotaWarning.svelte';
|
|
8
|
+
export { default as UpgradePrompt } from './UpgradePrompt.svelte';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Components
|
|
3
|
+
*
|
|
4
|
+
* UI components for displaying and managing post quotas.
|
|
5
|
+
*/
|
|
6
|
+
export { default as QuotaWidget } from './QuotaWidget.svelte';
|
|
7
|
+
export { default as QuotaWarning } from './QuotaWarning.svelte';
|
|
8
|
+
export { default as UpgradePrompt } from './UpgradePrompt.svelte';
|