mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add streaks, scheduled releases, and notification settings
- Couple streaks with milestone notifications - Scheduled pack releases with admin UI - Notification preferences screen - Refactored profile settings screens - Partner activity notifications
This commit is contained in:
1 parent
12f312421b
commit
494ed17fe2
82 files changed
+4189
-725
No files matched your search
@@ -45,6 +45,8 @@ export interface PackFormData {
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
category_id: string;
|
||||
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||
skip_notification?: boolean; // If true, sets release_notified=true to skip notifications
|
||||
}
|
||||
|
||||
export interface PackFormDialogProps {
|
||||
@@ -200,10 +202,63 @@ export function PackFormDialog({
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_public}
|
||||
onCheckedChange={(checked) => setField('is_public', checked)}
|
||||
onCheckedChange={(checked) => {
|
||||
setField('is_public', checked);
|
||||
// Clear scheduled release if making public immediately
|
||||
if (checked) {
|
||||
setField('scheduled_release_at', null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scheduled Release - only show when not public */}
|
||||
{!formData.is_public && (
|
||||
<div className="space-y-4 p-3 rounded-md bg-muted/50 border border-dashed">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduled_release_at">Scheduled Release</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically make this pack public at a specific time. Users will be notified when it becomes available.
|
||||
</p>
|
||||
<Input
|
||||
id="scheduled_release_at"
|
||||
type="datetime-local"
|
||||
value={formData.scheduled_release_at ? new Date(formData.scheduled_release_at).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setField('scheduled_release_at', value ? new Date(value).toISOString() : null);
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{formData.scheduled_release_at && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setField('scheduled_release_at', null)}
|
||||
className="mt-1"
|
||||
>
|
||||
Clear scheduled release
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skip Notification Toggle */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Skip release notification</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Release without notifying users
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.skip_notification ?? false}
|
||||
onCheckedChange={(checked) => setField('skip_notification', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explicit Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||
@@ -41,6 +41,8 @@ interface QuestionPack {
|
||||
sort_order: number | null;
|
||||
category_id: string | null;
|
||||
created_at: string | null;
|
||||
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||
release_notified?: boolean; // True if notifications have been sent or should be skipped
|
||||
question_count?: number;
|
||||
topics?: Topic[];
|
||||
}
|
||||
@@ -76,6 +78,8 @@ export function PacksPage() {
|
||||
is_public: false,
|
||||
is_explicit: false,
|
||||
category_id: categoryId ?? '',
|
||||
scheduled_release_at: null,
|
||||
skip_notification: false,
|
||||
},
|
||||
(pack) => ({
|
||||
name: pack.name,
|
||||
@@ -85,6 +89,8 @@ export function PacksPage() {
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
category_id: pack.category_id || '',
|
||||
scheduled_release_at: pack.scheduled_release_at,
|
||||
skip_notification: false, // Always start unchecked when editing
|
||||
})
|
||||
);
|
||||
|
||||
@@ -223,6 +229,13 @@ export function PacksPage() {
|
||||
let packId: string;
|
||||
|
||||
if (form.editingItem) {
|
||||
// Determine release_notified value
|
||||
// If skip_notification is checked, set release_notified=true to prevent notifications
|
||||
// Otherwise, reset to false when scheduling a new release
|
||||
const releaseNotifiedValue = form.formData.skip_notification
|
||||
? true
|
||||
: (form.formData.scheduled_release_at ? false : undefined);
|
||||
|
||||
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
@@ -231,6 +244,12 @@ export function PacksPage() {
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: form.formData.category_id || null,
|
||||
// Only include scheduled_release_at if the field exists in form data (backwards compatible)
|
||||
...('scheduled_release_at' in form.formData && {
|
||||
scheduled_release_at: form.formData.scheduled_release_at,
|
||||
// Set release_notified based on skip_notification toggle
|
||||
...(releaseNotifiedValue !== undefined && { release_notified: releaseNotifiedValue }),
|
||||
}),
|
||||
});
|
||||
if (error) throw error;
|
||||
packId = form.editingItem.id;
|
||||
@@ -246,6 +265,12 @@ export function PacksPage() {
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: form.formData.category_id || null,
|
||||
sort_order: totalCount,
|
||||
// Only include scheduled_release_at if the field exists in form data (backwards compatible)
|
||||
...('scheduled_release_at' in form.formData && {
|
||||
scheduled_release_at: form.formData.scheduled_release_at,
|
||||
// If skip_notification is checked, set release_notified=true
|
||||
...(form.formData.skip_notification && { release_notified: true }),
|
||||
}),
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
@@ -305,6 +330,8 @@ export function PacksPage() {
|
||||
is_public: false,
|
||||
is_explicit: false,
|
||||
category_id: categoryId ?? '',
|
||||
scheduled_release_at: null,
|
||||
skip_notification: false,
|
||||
});
|
||||
setAiDialogOpen(false);
|
||||
setSelectedTopicIds(new Set());
|
||||
@@ -497,6 +524,18 @@ export function PacksPage() {
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Published
|
||||
</Badge>
|
||||
) : pack.scheduled_release_at ? (
|
||||
<>
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-400" title={`Releases: ${new Date(pack.scheduled_release_at).toLocaleString()}`}>
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Scheduled
|
||||
</Badge>
|
||||
{pack.release_notified && (
|
||||
<Badge variant="outline" className="border-gray-500 text-gray-400" title="Notifications skipped">
|
||||
<BellOff className="h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<EyeOff className="h-3 w-3 mr-1" />
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles } from 'lucide-react';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
|
||||
@@ -142,6 +142,17 @@ interface FeatureInterest {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface CoupleStreak {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_active_date: string | null;
|
||||
last_milestone_celebrated: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const answerIcons = {
|
||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||
@@ -277,6 +288,7 @@ export function UserDetailPage() {
|
||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
|
||||
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
|
||||
const [streak, setStreak] = useState<CoupleStreak | null>(null);
|
||||
const [responsesPage, setResponsesPage] = useState(1);
|
||||
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||
@@ -357,8 +369,17 @@ export function UserDetailPage() {
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
setPartner(partnerData);
|
||||
|
||||
// Fetch streak data
|
||||
const { data: streakData } = await supabase
|
||||
.from('couple_streaks')
|
||||
.select('*')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.maybeSingle();
|
||||
setStreak(streakData);
|
||||
} else {
|
||||
setPartner(null);
|
||||
setStreak(null);
|
||||
}
|
||||
|
||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||
@@ -534,6 +555,58 @@ export function UserDetailPage() {
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||
</div></CardContent></Card>
|
||||
{/* Streak Info */}
|
||||
{profile.couple_id && streak && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
Couple Streak
|
||||
</CardTitle>
|
||||
<CardDescription>Activity streak for this couple</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-900">
|
||||
<Flame className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Current Streak</p>
|
||||
<p className="font-medium text-xl">{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-900">
|
||||
<Trophy className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Longest Streak</p>
|
||||
<p className="font-medium text-xl">{streak.longest_streak} day{streak.longest_streak !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Last Active</p>
|
||||
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Last Milestone</p>
|
||||
<p className="font-medium">{streak.last_milestone_celebrated ? `${streak.last_milestone_celebrated} days` : 'None yet'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Feature Opt-Ins */}
|
||||
{featureInterests.length > 0 && (
|
||||
<Card>
|
||||
|
||||
Reference in new issue
Block a user