Update
No files matched your search
@@ -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<TextAnalysis[]>([]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Wand2 className="h-5 w-5 text-purple-500" />
|
||||||
|
Fix Question Text
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Use AI to improve question phrasing to be direct, actionable proposals.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
{step === 'initial' ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
|
||||||
|
<div className="bg-purple-50 p-4 rounded-full">
|
||||||
|
<Wand2 className="h-12 w-12 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
|
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?").
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-purple-600 hover:bg-purple-700">
|
||||||
|
{analyzing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Analyzing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Start Analysis'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{suggestions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
|
||||||
|
All questions are well-phrased! No improvements needed.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 accent-primary"
|
||||||
|
checked={selectedIds.size === suggestions.length && suggestions.length > 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) setSelectedIds(new Set(suggestions.map(s => s.id)));
|
||||||
|
else setSelectedIds(new Set());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Current Text</TableHead>
|
||||||
|
<TableHead className="w-8"></TableHead>
|
||||||
|
<TableHead>Suggested Text</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{suggestions.map((suggestion) => {
|
||||||
|
const question = getQuestion(suggestion.id);
|
||||||
|
if (!question) return null;
|
||||||
|
return (
|
||||||
|
<TableRow key={suggestion.id}>
|
||||||
|
<TableCell>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 accent-primary"
|
||||||
|
checked={selectedIds.has(suggestion.id)}
|
||||||
|
onChange={() => toggleSelection(suggestion.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs">
|
||||||
|
<div className="text-sm">{question.text}</div>
|
||||||
|
{question.partner_text && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-1 italic">
|
||||||
|
Partner: {question.partner_text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs">
|
||||||
|
<div className="text-sm font-medium text-purple-700">{suggestion.suggested_text}</div>
|
||||||
|
{suggestion.suggested_partner_text && (
|
||||||
|
<div className="text-xs text-purple-600 mt-1 italic">
|
||||||
|
Partner: {suggestion.suggested_partner_text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground max-w-xs">
|
||||||
|
{suggestion.reason}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{step === 'review' && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setStep('initial')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applying || suggestions.length === 0 || selectedIds.size === 0}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
{applying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Applying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Apply ${selectedIds.size} Fixes`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CombinedSuggestion[]>([]);
|
||||||
|
const [selectedTextIds, setSelectedTextIds] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedTargetIds, setSelectedTargetIds] = useState<Set<string>>(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<string, unknown> }[] = [];
|
||||||
|
|
||||||
|
// 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<string, unknown> = {};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||||
|
Review Questions
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
AI-powered analysis to improve text phrasing and fix audience targeting.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
{step === 'initial' ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center space-y-6">
|
||||||
|
<div className="bg-amber-50 p-4 rounded-full">
|
||||||
|
<Sparkles className="h-12 w-12 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
|
||||||
|
<p className="text-muted-foreground max-w-lg mx-auto">
|
||||||
|
The AI will analyze all questions and suggest improvements for:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex items-center gap-2 bg-purple-50 px-4 py-2 rounded-lg">
|
||||||
|
<Wand2 className="h-5 w-5 text-purple-500" />
|
||||||
|
<span className="text-sm font-medium">Text Phrasing</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-indigo-50 px-4 py-2 rounded-lg">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-indigo-500" />
|
||||||
|
<span className="text-sm font-medium">Audience Targeting</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-amber-600 hover:bg-amber-700">
|
||||||
|
{analyzing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Analyzing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Start Review'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{suggestions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
|
||||||
|
All questions look great! No improvements needed.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary and bulk actions */}
|
||||||
|
<div className="flex items-center justify-between bg-muted/50 p-3 rounded-lg">
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Wand2 className="h-4 w-4 text-purple-500" />
|
||||||
|
{textSuggestionCount} text fixes
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-indigo-500" />
|
||||||
|
{targetSuggestionCount} target fixes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={selectAll}>
|
||||||
|
Select All
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={selectNone}>
|
||||||
|
Select None
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[300px]">Question</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wand2 className="h-4 w-4 text-purple-500" />
|
||||||
|
Text Fix
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => selectedTextIds.size === textSuggestionCount ? selectNoneText() : selectAllText()}
|
||||||
|
>
|
||||||
|
{selectedTextIds.size === textSuggestionCount ? 'None' : 'All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-indigo-500" />
|
||||||
|
Target Fix
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => selectedTargetIds.size === targetSuggestionCount ? selectNoneTargets() : selectAllTargets()}
|
||||||
|
>
|
||||||
|
{selectedTargetIds.size === targetSuggestionCount ? 'None' : 'All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<TableRow key={suggestion.questionId}>
|
||||||
|
{/* Question Column */}
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<div className="text-sm font-medium line-clamp-2">{suggestion.question.text}</div>
|
||||||
|
{suggestion.question.partner_text && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-1 italic line-clamp-1">
|
||||||
|
Partner: {suggestion.question.partner_text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Text Fix Column */}
|
||||||
|
<TableCell className="align-top">
|
||||||
|
{suggestion.textSuggestion ? (
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
|
||||||
|
? 'bg-purple-50 border-purple-300'
|
||||||
|
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleTextSelection(suggestion.questionId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedTextIds.has(suggestion.questionId)}
|
||||||
|
onCheckedChange={() => toggleTextSelection(suggestion.questionId)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-purple-700 font-medium">
|
||||||
|
{suggestion.textSuggestion.suggested_text}
|
||||||
|
</div>
|
||||||
|
{suggestion.textSuggestion.suggested_partner_text && (
|
||||||
|
<div className="text-xs text-purple-600 mt-1 italic">
|
||||||
|
Partner: {suggestion.textSuggestion.suggested_partner_text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{suggestion.textSuggestion.reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground p-2">
|
||||||
|
<Check className="h-4 w-4 text-green-500 inline mr-1" />
|
||||||
|
No changes needed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Target Fix Column */}
|
||||||
|
<TableCell className="align-top">
|
||||||
|
{suggestion.targetSuggestion ? (
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
|
||||||
|
? 'bg-indigo-50 border-indigo-300'
|
||||||
|
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleTargetSelection(suggestion.questionId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedTargetIds.has(suggestion.questionId)}
|
||||||
|
onCheckedChange={() => toggleTargetSelection(suggestion.questionId)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
{/* Couple targets */}
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||||
|
{formatTargets(suggestion.question.allowed_couple_genders)}
|
||||||
|
</Badge>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<Badge className="text-[10px] bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
|
||||||
|
{formatTargets(suggestion.targetSuggestion.suggested_targets)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{/* Initiator */}
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-300">
|
||||||
|
{formatInitiator(suggestion.question.target_user_genders)}
|
||||||
|
</Badge>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<Badge className="text-[10px] bg-orange-100 text-orange-700 hover:bg-orange-100">
|
||||||
|
{formatInitiator(suggestion.targetSuggestion.suggested_initiator)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{suggestion.targetSuggestion.reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground p-2">
|
||||||
|
<Check className="h-4 w-4 text-green-500 inline mr-1" />
|
||||||
|
No changes needed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{step === 'review' && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setStep('initial')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applying || suggestions.length === 0 || totalSelectedCount === 0}
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
{applying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Applying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Apply ${totalSelectedCount} Changes`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
export interface CheckboxProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
|
||||||
|
checked?: boolean;
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
({ className, checked, onCheckedChange, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
checked ? "bg-primary text-primary-foreground" : "bg-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => onCheckedChange?.(!checked)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{checked && (
|
||||||
|
<Check className="h-3 w-3 mx-auto" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Checkbox.displayName = "Checkbox";
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -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<string, unknown> | null,
|
||||||
|
newValues?: Record<string, unknown> | null
|
||||||
|
): Promise<void> {
|
||||||
|
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<T> {
|
||||||
|
data: T[] | null;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SingleResult<T> {
|
||||||
|
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<T extends { id: string }>(
|
||||||
|
table: string,
|
||||||
|
records: Omit<T, 'id' | 'created_at'>[] | Omit<T, 'id' | 'created_at'>
|
||||||
|
): Promise<InsertResult<T>> {
|
||||||
|
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<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: data as T[] | null, error };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single record with audit logging
|
||||||
|
*/
|
||||||
|
async update<T extends { id: string }>(
|
||||||
|
table: string,
|
||||||
|
id: string,
|
||||||
|
updates: Partial<T>
|
||||||
|
): Promise<SingleResult<T>> {
|
||||||
|
// Fetch old values before update
|
||||||
|
let oldValues: Record<string, unknown> | 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<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: data as T | null, error };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update multiple records matching a filter with audit logging
|
||||||
|
*/
|
||||||
|
async updateMany<T extends { id: string }>(
|
||||||
|
table: string,
|
||||||
|
filter: { column: string; value: unknown },
|
||||||
|
updates: Partial<T>
|
||||||
|
): Promise<InsertResult<T>> {
|
||||||
|
// Fetch old values before update
|
||||||
|
let oldRecords: Record<string, unknown>[] = [];
|
||||||
|
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<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> | 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<string, unknown>[] = [];
|
||||||
|
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<string, unknown> | 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 687 B |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 924 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 688 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -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 `<img>` 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 `<style>` tags)
|
||||||
|
- Background gradients may not render in all clients; solid fallback colors are defined
|
||||||
|
- Test thoroughly before deploying to production
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Confirm Your Email - Sauci</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #0d0d1a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #0d0d1a;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 520px;">
|
||||||
|
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<div style="font-size: 36px; font-weight: 700; background: linear-gradient(135deg, #e94560, #9b59b6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">
|
||||||
|
Sauci
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main Card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08);">
|
||||||
|
|
||||||
|
<!-- Top Gradient Accent -->
|
||||||
|
<tr>
|
||||||
|
<td style="height: 4px; background: linear-gradient(90deg, #e94560, #9b59b6); border-radius: 16px 16px 0 0;"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 32px;">
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 24px;">
|
||||||
|
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, rgba(233, 69, 96, 0.15), rgba(155, 89, 182, 0.15)); border-radius: 50%; display: inline-block; line-height: 64px; font-size: 28px;">
|
||||||
|
💌
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Heading -->
|
||||||
|
<h1 style="margin: 0 0 16px 0; font-size: 24px; font-weight: 600; color: #ffffff; text-align: center; line-height: 1.3;">
|
||||||
|
Welcome to Sauci
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Subtext -->
|
||||||
|
<p style="margin: 0 0 32px 0; font-size: 15px; color: rgba(255, 255, 255, 0.6); text-align: center; line-height: 1.6;">
|
||||||
|
You're one step away from deepening your connection. Confirm your email to start your journey together.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background: linear-gradient(135deg, #e94560, #9b59b6); color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; border-radius: 12px; box-shadow: 0 8px 24px rgba(233, 69, 96, 0.3);">
|
||||||
|
Confirm Email
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom: 24px;">
|
||||||
|
<div style="height: 1px; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Alternative Link -->
|
||||||
|
<p style="margin: 0; font-size: 13px; color: rgba(255, 255, 255, 0.4); text-align: center; line-height: 1.6;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: rgba(233, 69, 96, 0.8); text-align: center; word-break: break-all; line-height: 1.5;">
|
||||||
|
{{ .ConfirmationURL }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px 16px;">
|
||||||
|
<p style="margin: 0 0 8px 0; font-size: 13px; color: rgba(255, 255, 255, 0.4); text-align: center;">
|
||||||
|
This link will expire in 24 hours.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.3); text-align: center;">
|
||||||
|
If you didn't create an account, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Confirm Email Change - Sauci</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #0d0d1a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #0d0d1a;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 520px;">
|
||||||
|
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<div style="font-size: 36px; font-weight: 700; background: linear-gradient(135deg, #e94560, #9b59b6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">
|
||||||
|
Sauci
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main Card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08);">
|
||||||
|
|
||||||
|
<!-- Top Gradient Accent -->
|
||||||
|
<tr>
|
||||||
|
<td style="height: 4px; background: linear-gradient(90deg, #e94560, #9b59b6); border-radius: 16px 16px 0 0;"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 32px;">
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 24px;">
|
||||||
|
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, rgba(233, 69, 96, 0.15), rgba(155, 89, 182, 0.15)); border-radius: 50%; display: inline-block; line-height: 64px; font-size: 28px;">
|
||||||
|
✉
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Heading -->
|
||||||
|
<h1 style="margin: 0 0 16px 0; font-size: 24px; font-weight: 600; color: #ffffff; text-align: center; line-height: 1.3;">
|
||||||
|
Confirm Email Change
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Subtext -->
|
||||||
|
<p style="margin: 0 0 32px 0; font-size: 15px; color: rgba(255, 255, 255, 0.6); text-align: center; line-height: 1.6;">
|
||||||
|
You requested to change your email address. Click the button below to confirm this change.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background: linear-gradient(135deg, #e94560, #9b59b6); color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; border-radius: 12px; box-shadow: 0 8px 24px rgba(233, 69, 96, 0.3);">
|
||||||
|
Confirm New Email
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Info Notice -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: rgba(155, 89, 182, 0.08); border-radius: 8px; border: 1px solid rgba(155, 89, 182, 0.15);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 16px;">
|
||||||
|
<p style="margin: 0; font-size: 13px; color: rgba(255, 255, 255, 0.5); text-align: center; line-height: 1.5;">
|
||||||
|
📩 After confirming, you'll use your new email address to sign in to Sauci.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 0;">
|
||||||
|
<div style="height: 1px; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Alternative Link -->
|
||||||
|
<p style="margin: 0; font-size: 13px; color: rgba(255, 255, 255, 0.4); text-align: center; line-height: 1.6;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: rgba(233, 69, 96, 0.8); text-align: center; word-break: break-all; line-height: 1.5;">
|
||||||
|
{{ .ConfirmationURL }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px 16px;">
|
||||||
|
<p style="margin: 0 0 8px 0; font-size: 13px; color: rgba(255, 255, 255, 0.4); text-align: center;">
|
||||||
|
Didn't request this change?
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.3); text-align: center;">
|
||||||
|
If you didn't request an email change, please secure your account by resetting your password immediately.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>You're Invited to Sauci</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #0d0d1a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #0d0d1a;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 520px;">
|
||||||
|
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<div style="font-size: 36px; font-weight: 700; background: linear-gradient(135deg, #e94560, #9b59b6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">
|
||||||
|
Sauci
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main Card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08);">
|
||||||
|
|
||||||
|
<!-- Top Gradient Accent -->
|
||||||
|
<tr>
|
||||||
|
<td style="height: 4px; background: linear-gradient(90deg, #e94560, #9b59b6); border-radius: 16px 16px 0 0;"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 32px;">
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 24px;">
|
||||||
|
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, rgba(233, 69, 96, 0.15), rgba(155, 89, 182, 0.15)); border-radius: 50%; display: inline-block; line-height: 64px; font-size: 28px;">
|
||||||
|
💕
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Heading -->
|
||||||
|
<h1 style="margin: 0 0 16px 0; font-size: 24px; font-weight: 600; color: #ffffff; text-align: center; line-height: 1.3;">
|
||||||
|
You've Been Invited
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Subtext -->
|
||||||
|
<p style="margin: 0 0 32px 0; font-size: 15px; color: rgba(255, 255, 255, 0.6); text-align: center; line-height: 1.6;">
|
||||||
|
Someone special has invited you to join Sauci, the app designed to help couples deepen their connection and discover new things about each other.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background: linear-gradient(135deg, #e94560, #9b59b6); color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; border-radius: 12px; box-shadow: 0 8px 24px rgba(233, 69, 96, 0.3);">
|
||||||
|
Accept Invitation
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: rgba(22, 33, 62, 0.6); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.05);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px;">
|
||||||
|
<p style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.7); text-align: center; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
|
What awaits you
|
||||||
|
</p>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.5); text-align: center;">
|
||||||
|
🔥 Thoughtful questions to spark conversations
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.5); text-align: center;">
|
||||||
|
💌 Discover mutual interests with your partner
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.5); text-align: center;">
|
||||||
|
💬 Private chats when you both match
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 0;">
|
||||||
|
<div style="height: 1px; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Alternative Link -->
|
||||||
|
<p style="margin: 0; font-size: 13px; color: rgba(255, 255, 255, 0.4); text-align: center; line-height: 1.6;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: rgba(233, 69, 96, 0.8); text-align: center; word-break: break-all; line-height: 1.5;">
|
||||||
|
{{ .ConfirmationURL }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px 16px;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.3); text-align: center;">
|
||||||
|
This invitation link will expire in 7 days. If you didn't expect this invitation, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Sign In to Sauci</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #0d0d1a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #0d0d1a;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 520px;">
|
||||||
|
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<div style="font-size: 36px; font-weight: 700; background: linear-gradient(135deg, #e94560, #9b59b6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">
|
||||||
|
Sauci
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main Card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08);">
|
||||||
|
|
||||||
|
<!-- Top Gradient Accent -->
|
||||||
|
<tr>
|
||||||
|
<td style="height: 4px; background: linear-gradient(90deg, #e94560, #9b59b6); border-radius: 16px 16px 0 0;"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 32px;">
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 24px;">
|
||||||
|
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, rgba(233, 69, 96, 0.15), rgba(155, 89, 182, 0.15)); border-radius: 50%; display: inline-block; line-height: 64px; font-size: 28px;">
|
||||||
|
✨
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Heading -->
|
||||||
|
<h1 style="margin: 0 0 16px 0; font-size: 24px; font-weight: 600; color: #ffffff; text-align: center; line-height: 1.3;">
|
||||||
|
Your Magic Link
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Subtext -->
|
||||||
|
<p style="margin: 0 0 32px 0; font-size: 15px; color: rgba(255, 255, 255, 0.6); text-align: center; line-height: 1.6;">
|
||||||
|
Click the button below to securely sign in to your Sauci account. No password needed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background: linear-gradient(135deg, #e94560, #9b59b6); color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; border-radius: 12px; box-shadow: 0 8px 24px rgba(233, 69, 96, 0.3);">
|
||||||
|
Sign In to Sauci
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: rgba(233, 69, 96, 0.08); border-radius: 8px; border: 1px solid rgba(233, 69, 96, 0.15);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 16px;">
|
||||||
|
<p style="margin: 0; font-size: 13px; color: rgba(255, 255, 255, 0.5); text-align: center; line-height: 1.5;">
|
||||||
|
🔒 This link is unique to you and expires in 1 hour for your security.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 0;">
|
||||||
|
<div style="height: 1px; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Alternative Link -->
|
||||||
|
<p style="margin: 0; font-size: 13px; color: rgba(255, 255, 255, 0.4); text-align: center; line-height: 1.6;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: rgba(233, 69, 96, 0.8); text-align: center; word-break: break-all; line-height: 1.5;">
|
||||||
|
{{ .ConfirmationURL }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px 16px;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.3); text-align: center;">
|
||||||
|
If you didn't request this sign in link, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Reset Your Password - Sauci</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #0d0d1a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #0d0d1a;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 520px;">
|
||||||
|
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<div style="font-size: 36px; font-weight: 700; background: linear-gradient(135deg, #e94560, #9b59b6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">
|
||||||
|
Sauci
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main Card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08);">
|
||||||
|
|
||||||
|
<!-- Top Gradient Accent -->
|
||||||
|
<tr>
|
||||||
|
<td style="height: 4px; background: linear-gradient(90deg, #e94560, #9b59b6); border-radius: 16px 16px 0 0;"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 32px;">
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 24px;">
|
||||||
|
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, rgba(233, 69, 96, 0.15), rgba(155, 89, 182, 0.15)); border-radius: 50%; display: inline-block; line-height: 64px; font-size: 28px;">
|
||||||
|
🔐
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Heading -->
|
||||||
|
<h1 style="margin: 0 0 16px 0; font-size: 24px; font-weight: 600; color: #ffffff; text-align: center; line-height: 1.3;">
|
||||||
|
Reset Your Password
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Subtext -->
|
||||||
|
<p style="margin: 0 0 32px 0; font-size: 15px; color: rgba(255, 255, 255, 0.6); text-align: center; line-height: 1.6;">
|
||||||
|
We received a request to reset your password. Click the button below to choose a new one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background: linear-gradient(135deg, #e94560, #9b59b6); color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; border-radius: 12px; box-shadow: 0 8px 24px rgba(233, 69, 96, 0.3);">
|
||||||
|
Reset Password
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: rgba(155, 89, 182, 0.08); border-radius: 8px; border: 1px solid rgba(155, 89, 182, 0.15);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 16px;">
|
||||||
|
<p style="margin: 0; font-size: 13px; color: rgba(255, 255, 255, 0.5); text-align: center; line-height: 1.5;">
|
||||||
|
⏰ This link expires in 1 hour. After resetting, you'll need to sign in again on all devices.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 0;">
|
||||||
|
<div style="height: 1px; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Alternative Link -->
|
||||||
|
<p style="margin: 0; font-size: 13px; color: rgba(255, 255, 255, 0.4); text-align: center; line-height: 1.6;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: rgba(233, 69, 96, 0.8); text-align: center; word-break: break-all; line-height: 1.5;">
|
||||||
|
{{ .ConfirmationURL }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px 16px;">
|
||||||
|
<p style="margin: 0 0 8px 0; font-size: 13px; color: rgba(255, 255, 255, 0.4); text-align: center;">
|
||||||
|
Didn't request this?
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.3); text-align: center;">
|
||||||
|
If you didn't request a password reset, you can safely ignore this email. Your password won't change until you click the link above.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 179 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 687 B |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 924 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 688 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "AppIcon@2x.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon@3x.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon@2x~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-83.5@2x~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "83.5x83.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-40@2x.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-40@3x.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-40~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-40@2x~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-20@2x.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-20@3x.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-20~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-20@2x~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-29.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-29@2x.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-29@3x.png",
|
||||||
|
"idiom": "iphone",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-29~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-29@2x~ipad.png",
|
||||||
|
"idiom": "ipad",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-60@2x~car.png",
|
||||||
|
"idiom": "car",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon-60@3x~car.png",
|
||||||
|
"idiom": "car",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "AppIcon~ios-marketing.png",
|
||||||
|
"idiom": "ios-marketing",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "iconkitchen",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
Add this to your HTML <head>:
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
|
Add this to your app's manifest.json:
|
||||||
|
|
||||||
|
...
|
||||||
|
{
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/favicon.ico", "type": "image/x-icon", "sizes": "16x16 32x32" },
|
||||||
|
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
|
||||||
|
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" },
|
||||||
|
{ "src": "/icon-192-maskable.png", "type": "image/png", "sizes": "192x192", "purpose": "maskable" },
|
||||||
|
{ "src": "/icon-512-maskable.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
...
|
||||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 31 KiB |