diff --git a/apps/rebreak-native/components/blocker/SuggestCuratedSheet.tsx b/apps/rebreak-native/components/blocker/SuggestCuratedSheet.tsx new file mode 100644 index 0000000..f2fbc8b --- /dev/null +++ b/apps/rebreak-native/components/blocker/SuggestCuratedSheet.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react'; +import { + ActivityIndicator, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { normalizeDomain, isValidDomain } from '../../hooks/useCustomDomains'; +import { resolveVipCountry } from '../../hooks/useWebContentDomains'; +import { useCuratedSuggest } from '../../hooks/useCuratedSuggest'; +import { useColors } from '../../lib/theme'; +import { FormSheet } from '../FormSheet'; + +type Props = { + visible: boolean; + onClose: () => void; +}; + +export function SuggestCuratedSheet({ visible, onClose }: Props) { + const { t } = useTranslation(); + const colors = useColors(); + const [input, setInput] = useState(''); + const { state, suggest, reset } = useCuratedSuggest(); + + const loading = state === 'loading'; + const done = state === 'success'; + + const normalized = normalizeDomain(input); + const inputValid = isValidDomain(input); + + function close() { + setInput(''); + reset(); + onClose(); + } + + async function handleSubmit() { + if (!inputValid || loading) return; + const country = resolveVipCountry(); + await suggest(normalized, country); + } + + const errorMessage: string | null = (() => { + switch (state) { + case 'invalid_domain': return t('blocker.suggest_curated_error_invalid_domain'); + case 'already_suggested': return t('blocker.suggest_curated_error_already_suggested'); + case 'already_approved': return t('blocker.suggest_curated_error_already_approved'); + case 'already_rejected': return t('blocker.suggest_curated_error_already_rejected'); + case 'error': return t('blocker.suggest_curated_error_generic'); + default: return null; + } + })(); + + const ctaEnabled = inputValid && !loading && !done; + + return ( + + + {done ? ( + + + + + + {t('blocker.suggest_curated_success_title')} + + + {t('blocker.suggest_curated_success_body')} + + + + {t('common.ok')} + + + + ) : ( + <> + {/* Explanation card */} + + + + {t('blocker.suggest_curated_explanation')} + + + + {/* Input */} + + + {t('blocker.suggest_curated_input_label')} + + { + setInput(v); + if (state !== 'idle' && state !== 'loading') reset(); + }} + placeholder={t('blocker.suggest_curated_input_placeholder')} + placeholderTextColor={colors.textMuted} + keyboardType="url" + autoCapitalize="none" + autoCorrect={false} + style={{ + backgroundColor: colors.surfaceElevated, + borderRadius: 10, + padding: 12, + fontSize: 14, + fontFamily: 'Nunito_400Regular', + color: colors.text, + borderWidth: 1, + borderColor: errorMessage ? '#dc2626' : colors.border, + }} + /> + {errorMessage && ( + + {errorMessage} + + )} + + + {/* CTA row */} + + + + + {t('common.cancel')} + + + + + + + {loading ? ( + + ) : ( + + {t('blocker.suggest_curated_cta')} + + )} + + + + + )} + + + ); +} diff --git a/apps/rebreak-native/components/blocker/VipDomainList.tsx b/apps/rebreak-native/components/blocker/VipDomainList.tsx index a08b657..1ef24d6 100644 --- a/apps/rebreak-native/components/blocker/VipDomainList.tsx +++ b/apps/rebreak-native/components/blocker/VipDomainList.tsx @@ -8,6 +8,7 @@ import { ConfirmAlert } from '../ConfirmAlert'; import { SuccessAlert } from '../SuccessAlert'; import { useWebContentDomains } from '../../hooks/useWebContentDomains'; import type { CustomDomain, DomainStatus, Tier } from '../../hooks/useCustomDomains'; +import { SuggestCuratedSheet } from './SuggestCuratedSheet'; type VipCustomMeta = { status: DomainStatus; vipEvictAt: string | null | undefined }; @@ -447,6 +448,7 @@ type VipListProps = { export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) { const { t } = useTranslation(); const { domains: vipList, loading, refetch } = useWebContentDomains(); + const [suggestSheetVisible, setSuggestSheetVisible] = useState(false); const webCustoms = useMemo( () => domains.filter((d) => (d.type === 'web' || !d.type) && d.status !== 'rejected'), @@ -570,6 +572,7 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) title={t('blocker.vip_section_curated_title')} count={t('blocker.vip_section_curated_count', { count: curatedDomains.length })} colors={colors} + onSuggest={() => setSuggestSheetVisible(true)} > {curatedDomains.map((d) => ( @@ -594,6 +597,11 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) )} )} + + setSuggestSheetVisible(false)} + /> ); } @@ -603,12 +611,15 @@ function VipSubSection({ count, colors, children, + onSuggest, }: { title: string; count: string; colors: ColorScheme; children: React.ReactNode; + onSuggest?: () => void; }) { + const { t } = useTranslation(); return ( @@ -618,6 +629,19 @@ function VipSubSection({ {count} + {onSuggest && ( + + + {t('blocker.suggest_curated_link')} + + + )} {children} diff --git a/apps/rebreak-native/hooks/useCuratedSuggest.ts b/apps/rebreak-native/hooks/useCuratedSuggest.ts new file mode 100644 index 0000000..1041fbe --- /dev/null +++ b/apps/rebreak-native/hooks/useCuratedSuggest.ts @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { apiFetch } from '../lib/api'; + +type SuggestResult = + | { ok: true } + | { ok: false; alreadyExists: true; status: 'suggested' | 'approved' | 'rejected' } + | { ok: false; alreadyExists?: false; errorCode?: 'INVALID_DOMAIN' | 'INVALID_COUNTRY' | string }; + +export type SuggestState = 'idle' | 'loading' | 'success' | 'already_suggested' | 'already_approved' | 'already_rejected' | 'invalid_domain' | 'error'; + +export function useCuratedSuggest() { + const [state, setState] = useState('idle'); + + async function suggest(domain: string, country: string): Promise { + setState('loading'); + try { + const res = await apiFetch<{ ok: boolean; alreadyExists?: boolean; status?: string }>( + '/api/curated-domains/suggest', + { method: 'POST', body: { domain, country } }, + ); + if (res.ok) { + setState('success'); + return 'success'; + } + if (res.alreadyExists) { + const next: SuggestState = + res.status === 'approved' + ? 'already_approved' + : res.status === 'rejected' + ? 'already_rejected' + : 'already_suggested'; + setState(next); + return next; + } + setState('error'); + return 'error'; + } catch (e: any) { + const code: string = (e as any)?.code ?? ''; + if (code === 'INVALID_DOMAIN') { + setState('invalid_domain'); + return 'invalid_domain'; + } + setState('error'); + return 'error'; + } + } + + function reset() { + setState('idle'); + } + + return { state, suggest, reset }; +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 0b301e8..831c29c 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -416,7 +416,20 @@ "vip_swap_cta": "Austauschen", "vip_swap_no_candidates": "Keine tauschbaren Domains gefunden.", "vip_swap_error": "Austausch fehlgeschlagen. Bitte erneut versuchen.", - "vip_evict_badge": "wird in %{hours}h ersetzt" + "vip_evict_badge": "wird in %{hours}h ersetzt", + "suggest_curated_link": "+ Seite vorschlagen", + "suggest_curated_title": "Seite vorschlagen", + "suggest_curated_explanation": "Schlage eine Glücksspielseite für die Liste deines Landes vor — das ReBreak-Team prüft sie und nimmt sie bei Eignung auf.", + "suggest_curated_input_label": "Domain", + "suggest_curated_input_placeholder": "z.B. casino.com", + "suggest_curated_cta": "Vorschlag einreichen", + "suggest_curated_success_title": "Vorschlag eingereicht", + "suggest_curated_success_body": "Das Team prüft deinen Vorschlag und nimmt die Seite bei Eignung in die Liste auf.", + "suggest_curated_error_invalid_domain": "Bitte eine gültige Domain eingeben (z.B. casino.com).", + "suggest_curated_error_already_suggested": "Diese Domain wurde bereits vorgeschlagen und wird gerade geprüft.", + "suggest_curated_error_already_approved": "Diese Domain ist bereits in der kuratierten Liste.", + "suggest_curated_error_already_rejected": "Dieser Vorschlag wurde bereits geprüft und abgelehnt.", + "suggest_curated_error_generic": "Vorschlag fehlgeschlagen. Bitte später erneut versuchen." }, "onboarding": { "lyra": { diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index d5ead5f..f1e7cdc 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -416,7 +416,20 @@ "vip_swap_cta": "Swap", "vip_swap_no_candidates": "No swappable domains found.", "vip_swap_error": "Swap failed. Please try again.", - "vip_evict_badge": "replaced in %{hours}h" + "vip_evict_badge": "replaced in %{hours}h", + "suggest_curated_link": "+ Suggest a site", + "suggest_curated_title": "Suggest a site", + "suggest_curated_explanation": "Suggest a gambling site for your country's list — the ReBreak team reviews it and adds it if suitable.", + "suggest_curated_input_label": "Domain", + "suggest_curated_input_placeholder": "e.g. casino.com", + "suggest_curated_cta": "Submit suggestion", + "suggest_curated_success_title": "Suggestion submitted", + "suggest_curated_success_body": "The team will review your suggestion and add the site to the list if it qualifies.", + "suggest_curated_error_invalid_domain": "Please enter a valid domain (e.g. casino.com).", + "suggest_curated_error_already_suggested": "This domain has already been suggested and is currently under review.", + "suggest_curated_error_already_approved": "This domain is already in the curated list.", + "suggest_curated_error_already_rejected": "This suggestion has already been reviewed and rejected.", + "suggest_curated_error_generic": "Suggestion failed. Please try again later." }, "onboarding": { "lyra": {