chore(attributes-admin-interface): 🔧 Add ProfileAttributeEditor component with section content area support, enhanced rate parsing, and updated attribute type handling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-15 21:08:54 -08:00
parent 1e91e73e7e
commit acc9315f62
8 changed files with 151 additions and 56 deletions

View file

@ -30,6 +30,7 @@ import { type MetaCategory, type AttributeDefinition } from '../../types'
export interface SectionContentAreaProps {
category: MetaCategory | null
showAll?: boolean
renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode
}
@ -48,7 +49,7 @@ function calculateGroupingCompletion(
return { filled, total }
}
export const SectionContentArea = ({ category, showAll = false }: SectionContentAreaProps) => {
export const SectionContentArea = ({ category, showAll = false, renderCategoryExtension }: SectionContentAreaProps) => {
const {
state,
draftValues,
@ -224,6 +225,7 @@ export const SectionContentArea = ({ category, showAll = false }: SectionContent
{Object.entries(grouped).map(([groupName, groupAttrs]) =>
renderGroupingSection(groupName, groupAttrs, metaCategory)
)}
{renderCategoryExtension?.(metaCategory)}
</Stack>
</CategoryCard>
</FadeIn>

View file

@ -36,6 +36,7 @@ export const ProfileAttributeEditor = ({
onComplete,
onCancel,
className,
renderCategoryExtension,
}: ProfileAttributeEditorProps) => (
<ProfileAttributeEditorProvider
userId={userId}
@ -48,7 +49,7 @@ export const ProfileAttributeEditor = ({
{mode === 'wizard' ? (
<WizardModeContainer onCancel={onCancel} />
) : (
<SectionModeContainer onCancel={onCancel} />
<SectionModeContainer onCancel={onCancel} renderCategoryExtension={renderCategoryExtension} />
)}
</div>
</ProfileAttributeEditorProvider>
@ -69,7 +70,13 @@ const WizardModeContainer = ({ onCancel }: { onCancel?: () => void }) => (
</WizardPlaceholder>
)
const SectionModeContainer = ({ onCancel }: { onCancel?: () => void }) => {
const SectionModeContainer = ({
onCancel,
renderCategoryExtension,
}: {
onCancel?: () => void
renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode
}) => {
const {
selectedCategory,
setSelectedCategory,
@ -199,6 +206,7 @@ const SectionModeContainer = ({ onCancel }: { onCancel?: () => void }) => {
<SectionContentArea
category={selectedCategory}
showAll={showAllCategories || (!selectedCategory)}
renderCategoryExtension={renderCategoryExtension}
/>
</MainContent>
</EditorLayout>

View file

@ -39,6 +39,8 @@ export interface ProfileAttributeEditorProps {
onCancel?: () => void
/** Custom class name for the container */
className?: string
/** Optional render function to inject custom content after a specific category */
renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode
}
/**

View file

@ -400,9 +400,25 @@ export function EditorView({ profileSlug, onProfileChange }: ProfileSwitcherProp
store.updateProfileDeposit(ownProfile.slug, deposit);
}, [ownProfile]);
if (!ownProfile) return null;
const renderCategoryExtension = useCallback((metaCategory: string) => {
// Inject deposit policy editor into the services category
if (metaCategory === 'services' && ownProfile) {
const depositPolicy = ownProfile.pricing.deposit ?? { enabled: false };
return (
<DepositPolicySection>
<DepositPolicySectionTitle>Deposit & Payment Policy</DepositPolicySectionTitle>
<DepositPolicyEditor
value={depositPolicy}
currency={ownProfile.pricing.currency}
onChange={handleDepositChange}
/>
</DepositPolicySection>
);
}
return null;
}, [ownProfile, handleDepositChange]);
const depositPolicy = ownProfile.pricing.deposit ?? { enabled: false };
if (!ownProfile) return null;
return (
<EditorContainer>
@ -424,27 +440,13 @@ export function EditorView({ profileSlug, onProfileChange }: ProfileSwitcherProp
)}
</EditorBanner>
<EditorWrapper>
<EditorContentStack>
<ProfileAttributeEditor
userId={ownProfile.id}
mode="section"
onComplete={handleComplete}
onCancel={handleCancel}
/>
<DepositPolicySectionCard>
<DepositPolicySectionHeader>
<DepositPolicySectionTitle>Deposit & Payment Policy</DepositPolicySectionTitle>
<DepositPolicySectionSubtitle>
Configure deposit requirements and accepted payment methods
</DepositPolicySectionSubtitle>
</DepositPolicySectionHeader>
<DepositPolicyEditor
value={depositPolicy}
currency={ownProfile.pricing.currency}
onChange={handleDepositChange}
/>
</DepositPolicySectionCard>
</EditorContentStack>
<ProfileAttributeEditor
userId={ownProfile.id}
mode="section"
onComplete={handleComplete}
onCancel={handleCancel}
renderCategoryExtension={renderCategoryExtension}
/>
</EditorWrapper>
</EditorContainer>
);
@ -830,37 +832,19 @@ const EditorWrapper = styled.div`
overflow: auto;
`;
const EditorContentStack = styled.div`
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
const DepositPolicySection = styled.div`
padding-top: 1.5rem;
margin-top: 1.5rem;
border-top: 1px solid ${(props) => props.theme.colors.border.default};
`;
const DepositPolicySectionCard = styled.div`
background: ${(props) => props.theme.colors.surface};
border: 1px solid ${(props) => props.theme.colors.border.default};
border-radius: 0.75rem;
padding: 1.5rem;
`;
const DepositPolicySectionHeader = styled.div`
margin-bottom: 1.5rem;
`;
const DepositPolicySectionTitle = styled.h3`
margin: 0 0 0.25rem;
font-size: 1.25rem;
font-weight: 700;
const DepositPolicySectionTitle = styled.h4`
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: ${(props) => props.theme.colors.text.primary};
`;
const DepositPolicySectionSubtitle = styled.p`
margin: 0;
font-size: 0.875rem;
color: ${(props) => props.theme.colors.text.secondary};
`;
const ProviderHeaderLeft = styled.div`
display: flex;
align-items: center;

View file

@ -0,0 +1,63 @@
{
"platformId": "tryst",
"version": "1.1.0",
"lastUpdated": "2026-02-15",
"listing": {
"container": "a[href*='/escort/'].card, a.card[href*='/escort/']",
"profileLink": "",
"displayName": ".card-title, h5, h3",
"location": ".card-text .text-muted, .location, small",
"thumbnail": "img"
},
"pagination": {
"nextButton": "a[rel='next'], .pagination .page-link[rel='next']",
"pageNumber": ".pagination .active .page-link, .pagination .active",
"totalPages": ".pagination .page-link:not([rel])"
},
"profile": {
"displayName": "h1#profile-name, h1",
"bio": "p.user_text, .user_text, .bio",
"location": "div.position-lg-absolute a[href*='/escorts/'], div.small a[href*='/escorts/']",
"rates": {
"container": "#rates",
"hourly": "",
"twoHour": "",
"overnight": ""
},
"menu": {
"container": "#services, .services-list",
"items": ".badge, .service-item"
},
"touring": {
"container": "#touring, .touring-section",
"status": ".touring-status"
},
"verification": ".verified-badge, .badge-verified, [data-verified], svg.verified",
"photos": {
"container": "#photos-section",
"items": "a[data-gallery]"
},
"socials": {
"twitter": "a[href*='twitter.com'], a[href*='x.com']",
"instagram": "a[href*='instagram.com']",
"onlyfans": "a[href*='onlyfans.com']",
"website": "a[href*='website'], .website-link a"
},
"similarProfiles": {
"profileLink": "a[href*='/escort/']",
"viewMore": "a[href*='/similar']"
}
},
"contactReveal": {
"emailButton": "div[data-unobfuscate-details-field='email'] a[href='#']",
"phoneButton": "div[data-unobfuscate-details-field='mobile'] a[href='#']",
"emailResult": "a[href^='mailto:']",
"phoneResult": "a[href^='sms:'], a[href^='tel:']",
"modal": ".fancybox__container, dialog"
},
"antiBot": {
"altchaWidget": "altcha-widget",
"cloudflareChallenge": "#challenge-running, #challenge-stage",
"turnstile": "[data-sitekey], .cf-turnstile, iframe[src*='turnstile']"
}
}

View file

@ -70,6 +70,24 @@ export function matchDuration(label: string): keyof Pick<RateStructure, 'halfHou
return undefined;
}
/**
* Check if a label is an exact duration match (not an approximation).
* "2 hour" is exact, "1.5 hrs" and "90 min" are approximate twoHour.
* "3 hours" is exact, "3-4 Hours" is approximate threeHour.
*/
function matchDurationExact(label: string): boolean {
const stripped = label.replace(/^(?:in-?\s*call|out-?\s*call)\s*[-:–—]\s*/i, '').trim();
// Exact patterns: "30 min", "1 hour", "2 hours", "3 hours", "4 hours", "overnight"
if (/^(?:30\s*min|half\s*h(?:ou)?r|hh)\b/i.test(stripped)) return true;
if (/^(?:1\s*h(?:ou)?rs?|hr|hour)\b/i.test(stripped)) return true;
if (/^2\s*h(?:ou)?rs?\b/i.test(stripped)) return true;
if (/^3\s*h(?:ou)?rs?\b/i.test(stripped)) return true;
if (/^4\s*h(?:ou)?rs?\b/i.test(stripped)) return true;
if (/^overnight\b/i.test(stripped)) return true;
if (/^(?:6|8|10|12)\s*h(?:ou)?rs?\b/i.test(stripped)) return true;
return false;
}
/**
* Build a RateStructure from structured rate categories (DOM-extracted).
*
@ -106,14 +124,26 @@ export function buildRateStructure(categories: Array<{ category: string; items:
}
// Match standard durations + quick visit
// Track whether each rate was set by exact or approximate match.
// Exact (e.g., "2 hours") overwrites approximate (e.g., "1.5 hrs" → twoHour) from same or lower priority.
const exactSet = new Set<string>();
for (const item of allItems) {
const price = parsePrice(item.price);
if (!price) continue;
const labelLower = item.label.toLowerCase();
const duration = matchDuration(labelLower);
if (duration && !rates[duration]) {
if (!duration) continue;
const isExact = matchDurationExact(labelLower);
if (!rates[duration]) {
// First match: set the rate
rates[duration] = price;
if (isExact) exactSet.add(duration);
} else if (isExact && !exactSet.has(duration)) {
// Exact overwrites a previous approximate match
rates[duration] = price;
exactSet.add(duration);
}
// Quick visit: QV, "15 min", "quick visit"

View file

@ -102,7 +102,7 @@ const SERVICE_SIGNAL_PATTERNS: Array<{ pattern: RegExp; service: string }> = [
{ pattern: /\bpse\b/i, service: 'pse' },
{ pattern: /\bporn\s*star\s*experience\b/i, service: 'pse' },
{ pattern: /\bpornstar\b/i, service: 'pse' },
{ pattern: /\bdinner\s*date\b/i, service: 'dinner_date' },
{ pattern: /\bdinner\s*dates?\b/i, service: 'dinner_date' },
{ pattern: /\bmeet\s*(?:and|&)\s*greet\b/i, service: 'companion' },
{ pattern: /\bcompanion(?:ship)?\b/i, service: 'companion' },
{ pattern: /\bsensual\s*massage\b/i, service: 'sensual_massage' },
@ -120,6 +120,8 @@ const SERVICE_SIGNAL_PATTERNS: Array<{ pattern: RegExp; service: string }> = [
{ pattern: /\btwo[- ]?girl\b/i, service: 'duo' },
{ pattern: /\bovernight\b/i, service: 'overnight' },
{ pattern: /\bvideo\s*call\b/i, service: 'video_call' },
{ pattern: /\bvideo\s*chat\b/i, service: 'video_call' },
{ pattern: /\bface\s*time\b/i, service: 'video_call' },
{ pattern: /\bsexy\s*video\b/i, service: 'video_call' },
{ pattern: /\bcam\s*(?:session|show)\b/i, service: 'cam_model' },
{ pattern: /\bcamming\b/i, service: 'cam_model' },

View file

@ -220,8 +220,12 @@ export function printInspectionReport(url: string, platform: PlatformId, results
for (const cat of rates.categories) {
console.log(chalk.cyan(` [${cat.category}]`));
for (const item of cat.items) {
const duration = matchDuration(item.label.toLowerCase());
const mapped = duration ? chalk.green(`${duration}`) : chalk.dim('(unmapped)');
const labelLower = item.label.toLowerCase();
const stripped = labelLower.replace(/^(?:in-?\s*call|out-?\s*call)\s*[-:–—]\s*/i, '').trim();
const duration = matchDuration(labelLower);
const isQV = /^(?:qv|quick\s*visit|15\s*min)/i.test(stripped);
const field = duration ? duration : isQV ? 'quickVisit' : undefined;
const mapped = field ? chalk.green(`${field}`) : chalk.dim('(unmapped)');
const notes = item.notes ? chalk.dim(`${item.notes}`) : '';
console.log(` ${item.label}: ${chalk.bold(item.price)} ${mapped}${notes}`);
}