diff --git a/apps/admin/src/components/content/FixTextDialog.tsx b/apps/admin/src/components/content/FixTextDialog.tsx new file mode 100644 index 0000000..104c524 --- /dev/null +++ b/apps/admin/src/components/content/FixTextDialog.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Loader2, Wand2, ArrowRight, Check } from 'lucide-react'; +import { analyzeQuestionText, TextAnalysis } from '@/lib/openai'; +import { auditedSupabase } from '@/hooks/useAuditedSupabase'; +import { toast } from 'sonner'; + +interface Question { + id: string; + text: string; + partner_text: string | null; +} + +interface FixTextDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + questions: Question[]; + isExplicit?: boolean; + onUpdated: () => void; +} + +export function FixTextDialog({ open, onOpenChange, questions, isExplicit = false, onUpdated }: FixTextDialogProps) { + const [analyzing, setAnalyzing] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [step, setStep] = useState<'initial' | 'review'>('initial'); + const [applying, setApplying] = useState(false); + + const handleAnalyze = async () => { + setAnalyzing(true); + try { + const results = await analyzeQuestionText(questions, isExplicit); + setSuggestions(results); + // Default select all + setSelectedIds(new Set(results.map(r => r.id))); + setStep('review'); + } catch (error) { + console.error(error); + toast.error('Failed to analyze questions'); + } finally { + setAnalyzing(false); + } + }; + + const handleApply = async () => { + setApplying(true); + try { + const updates = suggestions.filter(s => selectedIds.has(s.id)); + if (updates.length === 0) { + onOpenChange(false); + return; + } + + // Apply updates in parallel with audit logging + await Promise.all(updates.map(update => + auditedSupabase.update('questions', update.id, { + text: update.suggested_text, + partner_text: update.suggested_partner_text, + }) + )); + + toast.success(`Updated ${updates.length} questions`); + onUpdated(); + handleClose(); + } catch (error) { + console.error(error); + toast.error('Failed to apply updates'); + } finally { + setApplying(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + // Reset state after transition + setTimeout(() => { + setStep('initial'); + setSuggestions([]); + }, 300); + }; + + const toggleSelection = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedIds(next); + }; + + const getQuestion = (id: string) => questions.find(q => q.id === id); + + return ( + + + + + + Fix Question Text + + + Use AI to improve question phrasing to be direct, actionable proposals. + + + +
+ {step === 'initial' ? ( +
+
+ +
+
+

Ready to analyze {questions.length} questions

+

+ The AI will suggest improved text to make questions more direct and actionable, + following the proposal style (e.g., "Give your partner a massage" instead of "Would you want to give a massage?"). +

+
+ +
+ ) : ( +
+ {suggestions.length === 0 ? ( +
+ + All questions are well-phrased! No improvements needed. +
+ ) : ( +
+ + + + + 0} + onChange={(e) => { + if (e.target.checked) setSelectedIds(new Set(suggestions.map(s => s.id))); + else setSelectedIds(new Set()); + }} + /> + + Current Text + + Suggested Text + Reason + + + + {suggestions.map((suggestion) => { + const question = getQuestion(suggestion.id); + if (!question) return null; + return ( + + + toggleSelection(suggestion.id)} + /> + + +
{question.text}
+ {question.partner_text && ( +
+ Partner: {question.partner_text} +
+ )} +
+ + + + +
{suggestion.suggested_text}
+ {suggestion.suggested_partner_text && ( +
+ Partner: {suggestion.suggested_partner_text} +
+ )} +
+ + {suggestion.reason} + +
+ ); + })} +
+
+
+ )} +
+ )} +
+ + + {step === 'review' && ( + <> + + + + )} + +
+
+ ); +} diff --git a/apps/admin/src/components/content/ReviewQuestionsDialog.tsx b/apps/admin/src/components/content/ReviewQuestionsDialog.tsx new file mode 100644 index 0000000..e92aa05 --- /dev/null +++ b/apps/admin/src/components/content/ReviewQuestionsDialog.tsx @@ -0,0 +1,490 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck } from 'lucide-react'; +import { analyzeQuestionText, analyzeQuestionTargets, TextAnalysis, TargetAnalysis } from '@/lib/openai'; +import { auditedSupabase } from '@/hooks/useAuditedSupabase'; +import { toast } from 'sonner'; + +interface Question { + id: string; + text: string; + partner_text: string | null; + intensity: number; + allowed_couple_genders: string[] | null; + target_user_genders: string[] | null; +} + +interface ReviewQuestionsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + questions: Question[]; + isExplicit?: boolean; + onUpdated: () => void; +} + +interface CombinedSuggestion { + questionId: string; + question: Question; + textSuggestion?: TextAnalysis; + targetSuggestion?: TargetAnalysis; +} + +export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplicit = false, onUpdated }: ReviewQuestionsDialogProps) { + const [analyzing, setAnalyzing] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [selectedTextIds, setSelectedTextIds] = useState>(new Set()); + const [selectedTargetIds, setSelectedTargetIds] = useState>(new Set()); + const [step, setStep] = useState<'initial' | 'review'>('initial'); + const [applying, setApplying] = useState(false); + + // Helper functions for target comparison + const isAllCoupleTargets = (targets: string[] | null): boolean => { + if (!targets || targets.length === 0) return true; + if (targets.length === 3) { + const sorted = [...targets].sort(); + return sorted[0] === 'female+female' && sorted[1] === 'female+male' && sorted[2] === 'male+male'; + } + return false; + }; + + const isAllInitiator = (targets: string[] | null): boolean => { + return !targets || targets.length === 0; + }; + + const arraysEqual = (a: string[] | null, b: string[] | null): boolean => { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, i) => val === sortedB[i]); + }; + + const handleAnalyze = async () => { + setAnalyzing(true); + try { + // Run both analyses in parallel + const [textResults, targetResults] = await Promise.all([ + analyzeQuestionText(questions, isExplicit), + analyzeQuestionTargets(questions) + ]); + + // Filter target results to only meaningful changes + const meaningfulTargetResults = targetResults.filter(suggestion => { + const question = questions.find(q => q.id === suggestion.id); + if (!question) return false; + + const currentCoupleIsAll = isAllCoupleTargets(question.allowed_couple_genders); + const suggestedCoupleIsAll = isAllCoupleTargets(suggestion.suggested_targets); + const coupleChanged = !(currentCoupleIsAll && suggestedCoupleIsAll) && + !arraysEqual(question.allowed_couple_genders, suggestion.suggested_targets); + + const currentInitiatorIsAll = isAllInitiator(question.target_user_genders); + const suggestedInitiatorIsAll = isAllInitiator(suggestion.suggested_initiator); + const initiatorChanged = !(currentInitiatorIsAll && suggestedInitiatorIsAll) && + !arraysEqual(question.target_user_genders, suggestion.suggested_initiator); + + return coupleChanged || initiatorChanged; + }); + + // Combine results by question ID + const questionIds = new Set([ + ...textResults.map(t => t.id), + ...meaningfulTargetResults.map(t => t.id) + ]); + + const combined: CombinedSuggestion[] = Array.from(questionIds).map(id => { + const question = questions.find(q => q.id === id)!; + return { + questionId: id, + question, + textSuggestion: textResults.find(t => t.id === id), + targetSuggestion: meaningfulTargetResults.find(t => t.id === id), + }; + }); + + setSuggestions(combined); + // Default select all + setSelectedTextIds(new Set(textResults.map(r => r.id))); + setSelectedTargetIds(new Set(meaningfulTargetResults.map(r => r.id))); + setStep('review'); + } catch (error) { + console.error(error); + toast.error('Failed to analyze questions'); + } finally { + setAnalyzing(false); + } + }; + + const handleApply = async () => { + setApplying(true); + try { + const updates: { id: string; data: Record }[] = []; + + // Build update objects combining text and target changes + suggestions.forEach(suggestion => { + const textSelected = selectedTextIds.has(suggestion.questionId) && suggestion.textSuggestion; + const targetSelected = selectedTargetIds.has(suggestion.questionId) && suggestion.targetSuggestion; + + if (textSelected || targetSelected) { + const updateData: Record = {}; + + if (textSelected && suggestion.textSuggestion) { + updateData.text = suggestion.textSuggestion.suggested_text; + updateData.partner_text = suggestion.textSuggestion.suggested_partner_text; + } + + if (targetSelected && suggestion.targetSuggestion) { + updateData.allowed_couple_genders = suggestion.targetSuggestion.suggested_targets && + suggestion.targetSuggestion.suggested_targets.length > 0 + ? suggestion.targetSuggestion.suggested_targets + : null; + updateData.target_user_genders = suggestion.targetSuggestion.suggested_initiator && + suggestion.targetSuggestion.suggested_initiator.length > 0 + ? suggestion.targetSuggestion.suggested_initiator + : null; + } + + if (Object.keys(updateData).length > 0) { + updates.push({ id: suggestion.questionId, data: updateData }); + } + } + }); + + if (updates.length === 0) { + onOpenChange(false); + return; + } + + // Apply updates in parallel with audit logging + await Promise.all(updates.map(update => + auditedSupabase.update('questions', update.id, update.data) + )); + + toast.success(`Updated ${updates.length} questions`); + onUpdated(); + handleClose(); + } catch (error) { + console.error(error); + toast.error('Failed to apply updates'); + } finally { + setApplying(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + setTimeout(() => { + setStep('initial'); + setSuggestions([]); + setSelectedTextIds(new Set()); + setSelectedTargetIds(new Set()); + }, 300); + }; + + const toggleTextSelection = (id: string) => { + const next = new Set(selectedTextIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedTextIds(next); + }; + + const toggleTargetSelection = (id: string) => { + const next = new Set(selectedTargetIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedTargetIds(next); + }; + + const formatTargets = (targets: string[] | null) => { + if (!targets || targets.length === 0) return 'All'; + return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', '); + }; + + const formatInitiator = (targets: string[] | null) => { + if (!targets || targets.length === 0) return 'Any'; + return targets.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(', '); + }; + + // Count suggestions by type + const textSuggestionCount = suggestions.filter(s => s.textSuggestion).length; + const targetSuggestionCount = suggestions.filter(s => s.targetSuggestion).length; + const totalSelectedCount = selectedTextIds.size + selectedTargetIds.size; + + // Select all/none helpers + const selectAllText = () => setSelectedTextIds(new Set(suggestions.filter(s => s.textSuggestion).map(s => s.questionId))); + const selectNoneText = () => setSelectedTextIds(new Set()); + const selectAllTargets = () => setSelectedTargetIds(new Set(suggestions.filter(s => s.targetSuggestion).map(s => s.questionId))); + const selectNoneTargets = () => setSelectedTargetIds(new Set()); + const selectAll = () => { + selectAllText(); + selectAllTargets(); + }; + const selectNone = () => { + selectNoneText(); + selectNoneTargets(); + }; + + return ( + + + + + + Review Questions + + + AI-powered analysis to improve text phrasing and fix audience targeting. + + + +
+ {step === 'initial' ? ( +
+
+ +
+
+

Ready to analyze {questions.length} questions

+

+ The AI will analyze all questions and suggest improvements for: +

+
+
+
+ + Text Phrasing +
+
+ + Audience Targeting +
+
+ +
+ ) : ( +
+ {suggestions.length === 0 ? ( +
+ + All questions look great! No improvements needed. +
+ ) : ( + <> + {/* Summary and bulk actions */} +
+
+ + + {textSuggestionCount} text fixes + + + + {targetSuggestionCount} target fixes + +
+
+ + +
+
+ +
+ + + + Question + +
+ + Text Fix + +
+
+ +
+ + Target Fix + +
+
+
+
+ + {suggestions.map((suggestion) => ( + + {/* Question Column */} + +
{suggestion.question.text}
+ {suggestion.question.partner_text && ( +
+ Partner: {suggestion.question.partner_text} +
+ )} +
+ + {/* Text Fix Column */} + + {suggestion.textSuggestion ? ( +
toggleTextSelection(suggestion.questionId)} + > +
+ toggleTextSelection(suggestion.questionId)} + className="mt-0.5" + /> +
+
+ {suggestion.textSuggestion.suggested_text} +
+ {suggestion.textSuggestion.suggested_partner_text && ( +
+ Partner: {suggestion.textSuggestion.suggested_partner_text} +
+ )} +
+ {suggestion.textSuggestion.reason} +
+
+
+
+ ) : ( +
+ + No changes needed +
+ )} +
+ + {/* Target Fix Column */} + + {suggestion.targetSuggestion ? ( +
toggleTargetSelection(suggestion.questionId)} + > +
+ toggleTargetSelection(suggestion.questionId)} + className="mt-0.5" + /> +
+ {/* Couple targets */} +
+ + {formatTargets(suggestion.question.allowed_couple_genders)} + + + + {formatTargets(suggestion.targetSuggestion.suggested_targets)} + +
+ {/* Initiator */} +
+ + {formatInitiator(suggestion.question.target_user_genders)} + + + + {formatInitiator(suggestion.targetSuggestion.suggested_initiator)} + +
+
+ {suggestion.targetSuggestion.reason} +
+
+
+
+ ) : ( +
+ + No changes needed +
+ )} +
+
+ ))} +
+
+
+ + )} +
+ )} +
+ + + {step === 'review' && ( + <> + + + + )} + +
+
+ ); +} diff --git a/apps/admin/src/components/ui/checkbox.tsx b/apps/admin/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..ec0d7a7 --- /dev/null +++ b/apps/admin/src/components/ui/checkbox.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; + +export interface CheckboxProps + extends Omit, 'type' | 'onChange'> { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; +} + +const Checkbox = React.forwardRef( + ({ className, checked, onCheckedChange, ...props }, ref) => { + return ( + + ); + } +); +Checkbox.displayName = "Checkbox"; + +export { Checkbox }; diff --git a/apps/admin/src/hooks/useAuditedSupabase.ts b/apps/admin/src/hooks/useAuditedSupabase.ts new file mode 100644 index 0000000..f6d58b4 --- /dev/null +++ b/apps/admin/src/hooks/useAuditedSupabase.ts @@ -0,0 +1,242 @@ +import { supabase } from '@/config'; + +// Tables to audit +const AUDITED_TABLES = [ + 'question_packs', + 'questions', + 'categories', + 'profiles', + 'couples', + 'responses', + 'matches', + 'messages', + 'subscriptions', + 'feedback', + 'admin_users', +]; + +type AuditAction = 'INSERT' | 'UPDATE' | 'DELETE'; + +async function logAction( + tableName: string, + action: AuditAction, + oldValues?: Record | null, + newValues?: Record | null +): Promise { + if (!AUDITED_TABLES.includes(tableName)) return; + + try { + const { error } = await supabase.rpc('log_admin_action', { + p_table_name: tableName, + p_action: action, + p_old_values: oldValues || null, + p_new_values: newValues || null, + }); + if (error) { + console.error('Failed to log audit action:', error); + } + } catch (error) { + console.error('Failed to log audit action:', error); + // Don't throw - logging failure shouldn't block the operation + } +} + +interface InsertResult { + data: T[] | null; + error: Error | null; +} + +interface SingleResult { + data: T | null; + error: Error | null; +} + +/** + * Audited Supabase operations - automatically logs INSERT/UPDATE/DELETE to audit_logs + */ +export const auditedSupabase = { + /** + * Insert records with audit logging + */ + async insert( + table: string, + records: Omit[] | Omit + ): Promise> { + const recordArray = Array.isArray(records) ? records : [records]; + + const { data, error } = await supabase + .from(table) + .insert(recordArray) + .select(); + + if (!error && data) { + // Log each inserted record + for (const record of data as T[]) { + await logAction(table, 'INSERT', null, record as Record); + } + } + + return { data: data as T[] | null, error }; + }, + + /** + * Update a single record with audit logging + */ + async update( + table: string, + id: string, + updates: Partial + ): Promise> { + // Fetch old values before update + let oldValues: Record | null = null; + if (AUDITED_TABLES.includes(table)) { + const { data: oldData } = await supabase + .from(table) + .select('*') + .eq('id', id) + .single(); + oldValues = oldData; + } + + const { data, error } = await supabase + .from(table) + .update(updates) + .eq('id', id) + .select() + .single(); + + if (!error && data) { + await logAction(table, 'UPDATE', oldValues, data as Record); + } + + return { data: data as T | null, error }; + }, + + /** + * Update multiple records matching a filter with audit logging + */ + async updateMany( + table: string, + filter: { column: string; value: unknown }, + updates: Partial + ): Promise> { + // Fetch old values before update + let oldRecords: Record[] = []; + if (AUDITED_TABLES.includes(table)) { + const { data: oldData } = await supabase + .from(table) + .select('*') + .eq(filter.column, filter.value); + oldRecords = oldData || []; + } + + const { data, error } = await supabase + .from(table) + .update(updates) + .eq(filter.column, filter.value) + .select(); + + if (!error && data) { + // Log each updated record + for (const record of data as T[]) { + const oldRecord = oldRecords.find(r => r.id === record.id); + await logAction(table, 'UPDATE', oldRecord || null, record as Record); + } + } + + return { data: data as T[] | null, error }; + }, + + /** + * Delete a single record with audit logging + */ + async delete( + table: string, + id: string + ): Promise<{ error: Error | null }> { + // Fetch old values before delete + let oldValues: Record | null = null; + if (AUDITED_TABLES.includes(table)) { + const { data: oldData } = await supabase + .from(table) + .select('*') + .eq('id', id) + .single(); + oldValues = oldData; + } + + const { error } = await supabase + .from(table) + .delete() + .eq('id', id); + + if (!error) { + await logAction(table, 'DELETE', oldValues, null); + } + + return { error }; + }, + + /** + * Delete multiple records with audit logging + */ + async deleteMany( + table: string, + ids: string[] + ): Promise<{ error: Error | null }> { + // Fetch old values before delete + let oldRecords: Record[] = []; + if (AUDITED_TABLES.includes(table)) { + const { data: oldData } = await supabase + .from(table) + .select('*') + .in('id', ids); + oldRecords = oldData || []; + } + + const { error } = await supabase + .from(table) + .delete() + .in('id', ids); + + if (!error) { + // Log each deletion + for (const record of oldRecords) { + await logAction(table, 'DELETE', record, null); + } + } + + return { error }; + }, + + /** + * Delete a record by a custom field (not id) with audit logging + */ + async deleteBy( + table: string, + field: string, + value: string + ): Promise<{ error: Error | null }> { + // Fetch old values before delete + let oldValues: Record | null = null; + if (AUDITED_TABLES.includes(table)) { + const { data: oldData } = await supabase + .from(table) + .select('*') + .eq(field, value) + .single(); + oldValues = oldData; + } + + const { error } = await supabase + .from(table) + .delete() + .eq(field, value); + + if (!error && oldValues) { + await logAction(table, 'DELETE', oldValues, null); + } + + return { error }; + }, +}; diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..d6e667b Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..58bfe26 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6c970bc Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..6c970bc Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..36a9da6 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..93fb1ef Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4cdf69b Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..4cdf69b Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0041cc8 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..b8aa7bb Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..54b6c20 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..54b6c20 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..44640b3 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..d21b0b8 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2fb9260 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..2fb9260 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..e49ac32 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..6e53e59 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..45d6879 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..45d6879 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@2x.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@2x.png new file mode 100644 index 0000000..af8c113 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@2x.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png new file mode 100644 index 0000000..af8c113 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@3x.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@3x.png new file mode 100644 index 0000000..85dd6d0 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20@3x.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png new file mode 100644 index 0000000..3ec1d71 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29.png new file mode 100644 index 0000000..1078ba5 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@2x.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@2x.png new file mode 100644 index 0000000..c35fe7a Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@2x.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png new file mode 100644 index 0000000..c35fe7a Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@3x.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@3x.png new file mode 100644 index 0000000..8783ffe Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29@3x.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png new file mode 100644 index 0000000..1078ba5 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@2x.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@2x.png new file mode 100644 index 0000000..2de8516 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@2x.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png new file mode 100644 index 0000000..2de8516 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@3x.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@3x.png new file mode 100644 index 0000000..cb5f020 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40@3x.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png new file mode 100644 index 0000000..af8c113 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png new file mode 100644 index 0000000..cb5f020 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png new file mode 100644 index 0000000..69c5a54 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png new file mode 100644 index 0000000..216d1a8 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@2x.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@2x.png new file mode 100644 index 0000000..cb5f020 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@2x.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png new file mode 100644 index 0000000..108c341 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@3x.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@3x.png new file mode 100644 index 0000000..69c5a54 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon@3x.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png new file mode 100644 index 0000000..f3fbb44 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png differ diff --git a/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon~ipad.png b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon~ipad.png new file mode 100644 index 0000000..a133564 Binary files /dev/null and b/apps/mobile/ios/Sauci/Images.xcassets/AppIcon.appiconset/AppIcon~ipad.png differ diff --git a/apps/supabase/email-templates/README.md b/apps/supabase/email-templates/README.md new file mode 100644 index 0000000..81b3a58 --- /dev/null +++ b/apps/supabase/email-templates/README.md @@ -0,0 +1,94 @@ +# Sauci Email Templates + +Premium, on-brand email templates for Supabase authentication. + +## Templates Included + +| Template | Supabase Setting | Purpose | +|----------|------------------|---------| +| `confirmation.html` | Confirm signup | Email verification after user signs up | +| `magic-link.html` | Magic Link | Passwordless sign-in links | +| `password-reset.html` | Reset Password | Password recovery emails | +| `email-change.html` | Change Email Address | Confirm email address updates | +| `invite.html` | Invite user | Admin user invitations | + +## How to Apply + +### Option 1: Supabase Dashboard (Recommended) + +1. Go to your Supabase project dashboard +2. Navigate to **Authentication** → **Email Templates** +3. For each template: + - Select the template type from the tabs + - Copy the HTML content from the corresponding `.html` file + - Paste into the **Body** field + - Update the **Subject** field (suggestions below) + - Click **Save** + +### Option 2: Supabase CLI + +```bash +# Update config.toml in supabase/config.toml +# Add template paths under [auth.email] +``` + +## Recommended Subject Lines + +| Template | Subject Line | +|----------|-------------| +| Confirm signup | `Welcome to Sauci - Confirm Your Email` | +| Magic Link | `Your Sauci Sign In Link` | +| Reset Password | `Reset Your Sauci Password` | +| Change Email | `Confirm Your New Email Address - Sauci` | +| Invite user | `You've Been Invited to Sauci` | + +## Template Variables + +These templates use Supabase's Go template syntax: + +- `{{ .ConfirmationURL }}` - The action URL (confirm, reset, sign in) +- `{{ .Email }}` - The user's email address (available but not used) +- `{{ .Token }}` - Raw token (available but not used) + +## Design System + +Templates match Sauci's premium glass-morphism aesthetic: + +**Colors:** +- Primary: `#e94560` (Rose Pink) +- Secondary: `#9b59b6` (Purple) +- Background: `#0d0d1a` (Deep Dark) +- Surface: `#1a1a2e` / `#16213e` +- Text: `#ffffff` with varying opacity + +**Features:** +- Gradient accent bar at top of cards +- Subtle border styling (rgba white 8%) +- Linear gradients for backgrounds +- Shadow effects on CTA buttons +- Responsive design for all devices +- Outlook/Windows Mail compatible + +## Testing + +1. Use Supabase's "Send test email" feature in the dashboard +2. Test on multiple email clients: + - Gmail (web & mobile) + - Apple Mail + - Outlook + - Yahoo Mail + +## Customization + +To customize for your needs: + +1. **Logo**: Replace the text "Sauci" with an `` tag if you have a logo image hosted +2. **Colors**: Find/replace the hex values to match any brand updates +3. **Copy**: Adjust the messaging to fit your tone + +## Notes + +- Templates use table-based layouts for maximum email client compatibility +- Inline styles are used (email clients strip `