mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The product pages from /s3 to /zap, /university and its course data, /trust, /security, /sdks, /support, /tasks, /startups, /sms-opt-in, the buying specialist, Zen, Kai's guide, Studio, the settings pages, and the shared sales, solutions, zap and ui pieces. Client code draws with t(), server components with <T>, copy held as data is marked with phrase() and drawn through t() where it lands. A link or a figure inside a sentence is a value, so a translator reorders the whole sentence. Rebased on main: every page main moved onto Fold or Steps keeps main's layout, with its words wrapped (label, title, lede, points). Steps now draws a step's body through t, as it already drew the title. Settings keeps main's usage panel, refresh, rewards wallet and sign-in wallet rows, each label wrapped. Left English: the skills page's install line (a command and two URLs), and the solution eyebrow and sibling heading built from a kind label. Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
401 lines
21 KiB
TypeScript
401 lines
21 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* The savings calculator on /enterprise: what the tokens a team uses today cost
|
||
* at the providers' list prices, on Hanzo Cloud, and on GPUs of its own.
|
||
*
|
||
* EVERY RATE IS READ WHEN THE PAGE OPENS, and none is typed: the providers'
|
||
* rates and ours from the price list (GET /v1/pricing), a GPU hour from the
|
||
* same list's on-demand rates, a Team member's month from the plan list
|
||
* (GET /v1/billing/plans), and what Hanzo bills for a provider's model from
|
||
* the model list (GET /v1/models). Every model of the labs the price list prices
|
||
* is offered, its featured ones first (`labs`). The volumes, the people and the GPUs are the
|
||
* reader's; the ones the page opens with are an example and say so. Each column
|
||
* says what it assumes under its figure (lib/savings has the arithmetic).
|
||
*
|
||
* The Enterprise agreement is priced with our team, so no total here includes
|
||
* it, and the GPU column says so beside its figure.
|
||
*/
|
||
|
||
import { useMemo, useState, type ReactNode } from 'react'
|
||
import { ChevronDown, Minus, Plus } from 'lucide-react'
|
||
import { Button, Input, Label, Picker, Text, ToggleGroup, ToggleGroupItem, View, XStack, YStack } from '@hanzo/ui'
|
||
import { Loading, Unreachable } from '@/components/api/state'
|
||
import { usePlans, usePricing, useServed } from '@/lib/api/use'
|
||
import type { Lab, Own, Plan, Pricing, Served } from '@/lib/api/read'
|
||
import { bought, cost, even, gpus, HOURS, percent, rate, saves, usd, type Rates, type Tokens } from '@/lib/savings'
|
||
import { day } from '@/lib/tally'
|
||
import { CARD } from './kit'
|
||
import { phrase } from '@hanzo/i18n'
|
||
import { useI18n } from '@hanzo/i18n/react'
|
||
|
||
/** A row of use: a provider's model and what it takes a month. */
|
||
const ZERO = 0
|
||
const NO_TOKENS: Tokens = { input: ZERO, output: ZERO }
|
||
|
||
type Row = { model: string; mTokIn: number; mTokOut: number; spend: number; share: number }
|
||
|
||
/** How a reader states use: tokens a month, or dollars a month. */
|
||
type Mode = 'tokens' | 'spend'
|
||
|
||
/** The most rows a reader adds. */
|
||
const ROWS = 3
|
||
|
||
/**
|
||
* A lab's model the calculator can price: paid, with both rates, at its list
|
||
* price, and itself. A `:batch` row is the same model discounted, and a `~` row
|
||
* ("~anthropic/claude-opus-latest") is an alias of whichever model is the lab's
|
||
* latest, listed under a "~anthropic" provider of its own; both repeat a row
|
||
* that is already offered.
|
||
*/
|
||
const priced = (m: Lab) => !m.isFree && m.pricing.input > 0 && m.pricing.output > 0 && !m.id.endsWith(':batch') && !m.id.startsWith('~')
|
||
|
||
/**
|
||
* The models a reader picks from: every model of the labs the price list prices.
|
||
* The price list's featured ones lead, dearest first, and the page opens on the
|
||
* first of them; every other follows under its lab, A to Z. The featured set is
|
||
* curated in hanzoai/pricing (featuredModelIds); what is offered is not, so a
|
||
* lab's new model is here the day the price list lists it.
|
||
*/
|
||
function labs(doc: Pricing): { featured: Lab[]; byLab: [string, Lab[]][]; all: Lab[] } {
|
||
const paid = doc.thirdPartyModels.filter(priced)
|
||
const featured = paid.filter((m) => m.featured).sort((a, b) => b.pricing.output - a.pricing.output || a.name.localeCompare(b.name))
|
||
const rest = paid.filter((m) => !m.featured)
|
||
const byLab = [...new Set(rest.map((m) => m.provider))]
|
||
.sort((a, b) => a.localeCompare(b))
|
||
.map((p): [string, Lab[]] => [p, rest.filter((m) => m.provider === p).sort((a, b) => a.name.localeCompare(b.name))])
|
||
return { featured, byLab, all: [...featured, ...byLab.flatMap(([, ms]) => ms)] }
|
||
}
|
||
|
||
/** Ours to move the tokens to: the Enso SKUs the price list prices. */
|
||
const ours = (doc: Pricing): Own[] =>
|
||
doc.hanzoModels.filter((m) => m.family === 'enso' && m.pricing?.input != null && m.pricing.output != null && !m.name.includes('/'))
|
||
|
||
/** A provider's model by the name the reader reads: "Anthropic: Claude Opus 4.8" stays whole. */
|
||
const label = (m: Lab) => m.name
|
||
|
||
const at = (m: { pricing?: { input?: number | null; output?: number | null } }): Rates => ({ input: m.pricing?.input ?? 0, output: m.pricing?.output ?? 0 })
|
||
|
||
/** What Hanzo bills a provider's model at, from the model list, where it lists it. */
|
||
function billed(served: Served[] | null, id: string): Rates | null {
|
||
const p = served?.find((s) => s.id === id)?.pricing
|
||
return p?.input_per_million != null && p.output_per_million != null ? { input: p.input_per_million, output: p.output_per_million } : null
|
||
}
|
||
|
||
/** A number field's value, never below nothing. */
|
||
const amount = (v: string) => {
|
||
const n = Number(v)
|
||
return Number.isFinite(n) && n > 0 ? n : 0
|
||
}
|
||
|
||
const FIELD = { height: 48, fontSize: '1rem', rounded: 12, bg: 'var(--background)' } as const
|
||
const CHOICE = {
|
||
height: 48,
|
||
minH: 48,
|
||
py: 11,
|
||
pl: 14,
|
||
pr: 40,
|
||
fontSize: '1rem',
|
||
lineHeight: '1.5rem',
|
||
rounded: 12,
|
||
bg: 'var(--background)',
|
||
'$platform-web': { appearance: 'none', WebkitAppearance: 'none' },
|
||
} as const
|
||
|
||
function Field({ id, label: name, children }: { id: string; label: string; children: ReactNode }) {
|
||
return (
|
||
<YStack gap="$1.5" minW={0} flex={1}>
|
||
<Label htmlFor={id} fontSize="$2" color="var(--muted-foreground)">
|
||
{name}
|
||
</Label>
|
||
{children}
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/** A native select with its chevron, which the platform no longer draws. */
|
||
function Choice({ id, value, onChange, children }: { id: string; value: string; onChange: (v: string) => void; children: ReactNode }) {
|
||
return (
|
||
<View position="relative" minW={0}>
|
||
<Picker id={id} {...CHOICE} value={value} onChange={(e) => onChange(e.target.value)} width="100%">
|
||
{children}
|
||
</Picker>
|
||
<View position="absolute" r={14} t={0} b={0} justify="center" pointerEvents="none" aria-hidden>
|
||
<ChevronDown size={16} color="var(--muted-foreground)" />
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
function Amount({ id, value, onChange, step = 1 }: { id: string; value: number; onChange: (v: number) => void; step?: number }) {
|
||
return <Input id={id} {...FIELD} type="number" inputMode="decimal" min={0} step={step} value={String(value)} onChange={(e) => onChange(amount(e.target.value))} />
|
||
}
|
||
|
||
/** One of the three totals: its name, its figure, what it saves against list, and what it assumes. */
|
||
function Total({ slot, name, figure, unit, saving, children }: { slot: string; name: string; figure: string; unit: string; saving?: number | null; children: ReactNode }) {
|
||
const { t } = useI18n()
|
||
return (
|
||
<YStack gap="$2" py="$4" borderTopWidth={1} borderColor="var(--border)" data-figure={slot}>
|
||
<XStack justify="space-between" items="baseline" gap="$3" flexWrap="wrap">
|
||
<Text render="h3" fontSize="$4" fontWeight="600" color="var(--foreground)">
|
||
{name}
|
||
</Text>
|
||
{saving != null ? (
|
||
<Text fontSize="$2" fontWeight="600" color="var(--foreground)" px={10} py={2} rounded="var(--radius-full)" borderWidth={1} borderColor="var(--border-strong)" data-saving={slot}>
|
||
{saving >= 0 ? t('{percent} less than list', { percent: percent(saving) }) : t('{percent} more than list', { percent: percent(-saving) })}
|
||
</Text>
|
||
) : null}
|
||
</XStack>
|
||
<Text fontSize="2.25rem" lineHeight="2.5rem" fontWeight="500" color="var(--foreground)" $platform-web={{ fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>
|
||
{figure}
|
||
<Text render="span" fontSize="$3" fontWeight="400" color="var(--muted-foreground)">
|
||
{` ${unit}`}
|
||
</Text>
|
||
</Text>
|
||
<Text render="p" fontSize="$2" lineHeight="1.35rem" color="var(--muted-foreground)">
|
||
{children}
|
||
</Text>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
export default function Savings() {
|
||
const pricing = usePricing()
|
||
const plans = usePlans()
|
||
const served = useServed()
|
||
if (pricing.state === 'loading' || plans.state === 'loading') return <Loading what={phrase('the price list and the plans')} />
|
||
if (pricing.state === 'unreachable') return <Unreachable what={phrase('the price list')} />
|
||
return <Calculator doc={pricing.data} plans={plans.state === 'ready' ? plans.data : null} served={served.state === 'ready' ? served.data : null} />
|
||
}
|
||
|
||
function Calculator({ doc, plans, served }: { doc: Pricing; plans: Plan[] | null; served: Served[] | null }) {
|
||
const { t } = useI18n()
|
||
const offered = useMemo(() => labs(doc), [doc])
|
||
const choices = offered.all
|
||
const skus = useMemo(() => ours(doc), [doc])
|
||
const racks = doc.infrastructure?.gpu ?? []
|
||
const team = plans?.find((p) => p.category === 'team' && p.pricePerUser && (p.priceMonthly ?? 0) > 0) ?? null
|
||
|
||
// The example the page opens with: the dearest featured model, half a billion tokens in and a tenth of that out.
|
||
const [mode, setMode] = useState<Mode>('tokens')
|
||
const [rows, setRows] = useState<Row[]>(() => (choices[0] ? [{ model: choices[0].id, mTokIn: 500, mTokOut: 50, spend: 5000, share: 10 }] : []))
|
||
const [people, setPeople] = useState(50)
|
||
const [sku, setSku] = useState(() => skus.find((m) => m.name === 'enso')?.name ?? skus[0]?.name ?? '')
|
||
const [rack, setRack] = useState(() => racks[0]?.gpu ?? '')
|
||
const [count, setCount] = useState(1)
|
||
const config = racks.find((g) => g.gpu === rack) ?? racks[0]
|
||
const [hourly, setHourly] = useState<number | null>(null)
|
||
const hour = hourly ?? config?.price ?? 0
|
||
|
||
if (!choices.length || !skus.length) return <Unreachable what={phrase('the models the price list prices')} />
|
||
|
||
const mine = skus.find((m) => m.name === sku) ?? skus[0]
|
||
const set = (i: number, patch: Partial<Row>) => setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...patch } : r)))
|
||
|
||
// Each row as tokens a month, whichever way it was stated.
|
||
const usage = rows.map((r) => {
|
||
const m = choices.find((c) => c.id === r.model) ?? choices[0]
|
||
const tokens: Tokens = mode === 'tokens' ? { input: r.mTokIn, output: r.mTokOut } : (bought(r.spend, Math.min(100, r.share) / 100, at(m)) ?? NO_TOKENS)
|
||
return { m, tokens }
|
||
})
|
||
const total: Tokens = usage.reduce((t, u) => ({ input: t.input + u.tokens.input, output: t.output + u.tokens.output }), NO_TOKENS)
|
||
const listed = usage.reduce((s, u) => s + cost(u.tokens, at(u.m)), 0)
|
||
const onCloud = cost(total, at(mine))
|
||
const seats = team && people > 0 && (team.limits?.maxMembers == null || people <= Number(team.limits.maxMembers)) ? people * (team.priceMonthly ?? 0) : null
|
||
const cloud = onCloud + (seats ?? 0)
|
||
const fleet = gpus(count, hour)
|
||
const point = even(fleet, total, listed)
|
||
const kept = usage.every((u) => billed(served, u.m.id)) ? usage.reduce((s, u) => s + cost(u.tokens, billed(served, u.m.id)!), 0) : null
|
||
const millions = (v: number) =>
|
||
v >= 1000
|
||
? t('{n} billion', { n: (v / 1000).toLocaleString('en-US', { maximumFractionDigits: 2 }) })
|
||
: t('{n} million', { n: v.toLocaleString('en-US', { maximumFractionDigits: 1 }) })
|
||
// The Hanzo Cloud column's first sentence, which runs on into the seats where Team prices them.
|
||
const same = {
|
||
input: millions(total.input),
|
||
output: millions(total.output),
|
||
model: mine.fullName ?? mine.name,
|
||
inRate: rate(at(mine).input),
|
||
outRate: rate(at(mine).output),
|
||
cost: usd(onCloud),
|
||
}
|
||
const seated = (n: number, plan: string, seat: string, all: string) =>
|
||
n === 1
|
||
? t('The same {input} tokens in and {output} out on {model} at {inRate} in and {outRate} out per million: {cost}, plus {people} member on {plan} at {seat} a member a month: {seats}.', { ...same, people: n.toLocaleString('en-US'), plan, seat, seats: all })
|
||
: t('The same {input} tokens in and {output} out on {model} at {inRate} in and {outRate} out per million: {cost}, plus {people} members on {plan} at {seat} a member a month: {seats}.', { ...same, people: n.toLocaleString('en-US'), plan, seat, seats: all })
|
||
const alone = t('The same {input} tokens in and {output} out on {model} at {inRate} in and {outRate} out per million: {cost}.', same)
|
||
const gpu = config?.gpu ?? t('GPU')
|
||
|
||
return (
|
||
<View display="grid" gridTemplateColumns="minmax(0, 1fr)" gap={16} $lg={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)' }} data-slot="savings">
|
||
<YStack {...CARD} p={24} $sm={{ p: 28 }} gap="$5" minW={0}>
|
||
<XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
|
||
<Text render="h3" fontSize="$5" fontWeight="600" color="var(--foreground)">
|
||
{t('What you use a month')}
|
||
</Text>
|
||
<XStack p={4} rounded={9999} bg="var(--background)">
|
||
<ToggleGroup type="single" value={mode} onValueChange={(v) => v && setMode(v as Mode)} disableDeactivation aria-label={t('State use by')}>
|
||
<ToggleGroupItem value="tokens" variant="outline" rounded="$10" borderColor="transparent" px="$3">
|
||
{t('Tokens')}
|
||
</ToggleGroupItem>
|
||
<ToggleGroupItem value="spend" variant="outline" rounded="$10" borderColor="transparent" px="$3">
|
||
{t('Spend')}
|
||
</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
</XStack>
|
||
</XStack>
|
||
|
||
{rows.map((r, i) => (
|
||
<YStack key={i} gap="$3" data-row={i}>
|
||
{/* A field grows along a row; in its own row the model's keeps its height. */}
|
||
<XStack>
|
||
<Field id={`model-${i}`} label={i ? t('Model {n}', { n: i + 1 }) : t('Model you use today')}>
|
||
<Choice id={`model-${i}`} value={r.model} onChange={(v) => set(i, { model: v })}>
|
||
{offered.featured.length ? (
|
||
<optgroup label={t('Featured')}>
|
||
{offered.featured.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{label(m)}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
) : null}
|
||
{offered.byLab.map(([lab, ms]) => (
|
||
<optgroup key={lab} label={lab}>
|
||
{ms.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{label(m)}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</Choice>
|
||
</Field>
|
||
</XStack>
|
||
<XStack gap="$3" flexWrap="wrap">
|
||
{mode === 'tokens' ? (
|
||
<>
|
||
<Field id={`in-${i}`} label={t('Input tokens, millions')}>
|
||
<Amount id={`in-${i}`} value={r.mTokIn} onChange={(v) => set(i, { mTokIn: v })} />
|
||
</Field>
|
||
<Field id={`out-${i}`} label={t('Output tokens, millions')}>
|
||
<Amount id={`out-${i}`} value={r.mTokOut} onChange={(v) => set(i, { mTokOut: v })} />
|
||
</Field>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Field id={`spend-${i}`} label={t('Spend, dollars')}>
|
||
<Amount id={`spend-${i}`} value={r.spend} onChange={(v) => set(i, { spend: v })} step={100} />
|
||
</Field>
|
||
<Field id={`share-${i}`} label={t('Output share of tokens, %')}>
|
||
<Amount id={`share-${i}`} value={r.share} onChange={(v) => set(i, { share: Math.min(100, v) })} />
|
||
</Field>
|
||
</>
|
||
)}
|
||
</XStack>
|
||
{rows.length > 1 ? (
|
||
<Button size="sm" variant="ghost" self="flex-start" onPress={() => setRows((rs) => rs.filter((_, k) => k !== i))} aria-label={t('Remove {model}', { model: label(usage[i].m) })}>
|
||
<XStack items="center" gap="$1.5">
|
||
<Minus size={14} aria-hidden />
|
||
<Text fontSize="$2" color="inherit">
|
||
{t('Remove')}
|
||
</Text>
|
||
</XStack>
|
||
</Button>
|
||
) : null}
|
||
</YStack>
|
||
))}
|
||
{rows.length < ROWS ? (
|
||
<Button size="sm" variant="outline" self="flex-start" rounded="var(--radius-full)" onPress={() => setRows((rs) => [...rs, { model: choices[Math.min(rs.length, choices.length - 1)].id, mTokIn: 100, mTokOut: 10, spend: 1000, share: 10 }])}>
|
||
<XStack items="center" gap="$1.5">
|
||
<Plus size={14} aria-hidden />
|
||
<Text fontSize="$2" fontWeight="500" color="inherit">
|
||
{t('Add a model')}
|
||
</Text>
|
||
</XStack>
|
||
</Button>
|
||
) : null}
|
||
|
||
<YStack gap="$3" pt="$4" borderTopWidth={1} borderColor="var(--border)">
|
||
<XStack gap="$3" flexWrap="wrap">
|
||
<Field id="people" label={t('People')}>
|
||
<Amount id="people" value={people} onChange={(v) => setPeople(Math.floor(v))} />
|
||
</Field>
|
||
<Field id="sku" label={t('On Hanzo Cloud, moved to')}>
|
||
<Choice id="sku" value={mine.name} onChange={setSku}>
|
||
{skus.map((m) => (
|
||
<option key={m.name} value={m.name}>
|
||
{m.fullName ?? m.name}
|
||
</option>
|
||
))}
|
||
</Choice>
|
||
</Field>
|
||
</XStack>
|
||
{racks.length ? (
|
||
<XStack gap="$3" flexWrap="wrap">
|
||
<Field id="rack" label={t('Your GPUs')}>
|
||
<Choice
|
||
id="rack"
|
||
value={config?.gpu ?? ''}
|
||
onChange={(v) => {
|
||
setRack(v)
|
||
setHourly(null)
|
||
}}
|
||
>
|
||
{racks.map((g) => (
|
||
<option key={g.gpu} value={g.gpu}>
|
||
{`${g.gpu}, ${g.vram}`}
|
||
</option>
|
||
))}
|
||
</Choice>
|
||
</Field>
|
||
<Field id="count" label={t('How many')}>
|
||
<Amount id="count" value={count} onChange={(v) => setCount(Math.max(1, Math.floor(v)))} />
|
||
</Field>
|
||
<Field id="hour" label={t('An hour of it, dollars')}>
|
||
<Amount id="hour" value={hour} onChange={setHourly} step={0.01} />
|
||
</Field>
|
||
</XStack>
|
||
) : null}
|
||
<Text render="p" fontSize="$1" lineHeight="1.25rem" color="var(--muted-foreground)">
|
||
{doc.updated
|
||
? t("The page opens with an example. Put in your own volumes, people and GPUs; every rate is our price list's, as it stood on {date}.", { date: day(doc.updated) })
|
||
: t("The page opens with an example. Put in your own volumes, people and GPUs; every rate is our price list's.")}
|
||
</Text>
|
||
</YStack>
|
||
</YStack>
|
||
|
||
<YStack {...CARD} p={24} $sm={{ p: 28 }} gap={0} minW={0} aria-live="polite">
|
||
<Text render="h3" fontSize="$5" fontWeight="600" color="var(--foreground)" pb="$2">
|
||
{t('What it costs a month')}
|
||
</Text>
|
||
<Total slot="list" name={t('At list prices')} figure={usd(listed)} unit={t('a month')}>
|
||
{t("{rates}, the provider's rate as our price list carries it. Seats on a provider's own plans are not priced here.", {
|
||
rates: usage
|
||
.map((u) => t('{model} at {input} in and {output} out per million tokens', { model: label(u.m), input: rate(u.m.pricing.input), output: rate(u.m.pricing.output) }))
|
||
.join('; '),
|
||
})}
|
||
</Total>
|
||
<Total slot="cloud" name="Hanzo Cloud" figure={usd(cloud)} unit={t('a month')} saving={saves(cloud, listed)}>
|
||
{seats !== null && team
|
||
? `${seated(people, team.name, usd(team.priceMonthly ?? 0), usd(seats))} ${t('{plan} includes usage for each member; every token is priced here anyway.', { plan: team.name })}`
|
||
: team && team.limits?.maxMembers != null && people > Number(team.limits.maxMembers)
|
||
? `${alone} ${t('{plan} is for up to {max} members; past that, members are part of Enterprise.', { plan: team.name, max: String(team.limits.maxMembers) })}`
|
||
: alone}
|
||
{kept !== null ? ` ${t('Keeping the same models through Hanzo bills them at the rates our model list states: {cost} a month.', { cost: usd(kept) })}` : ''}
|
||
</Total>
|
||
<Total slot="gpus" name={t('Enterprise, on your GPUs')} figure={usd(fleet)} unit={t('a month, plus the agreement')} saving={saves(fleet, listed)}>
|
||
{`${
|
||
hourly === null
|
||
? t('{count} × {gpu} on all month ({hours} hours) at {rate} an hour, our price list’s on-demand rate; put in what an hour of your own costs.', { count, gpu, hours: HOURS, rate: usd(hour) })
|
||
: t('{count} × {gpu} on all month ({hours} hours) at {rate} an hour.', { count, gpu, hours: HOURS, rate: usd(hour) })
|
||
} ${t('No per-token charge, and members are unlimited. The agreement itself is priced with our team and is not in this figure.')}`}
|
||
{point
|
||
? ` ${t('These GPUs cost what list prices do at {tokens} tokens a month at your mix, about {rate} tokens a second around the clock. Whether they serve that is sized with you, against your models and your load.', { tokens: millions(point.millions), rate: Math.round(point.perSecond).toLocaleString('en-US') })}`
|
||
: ''}
|
||
</Total>
|
||
</YStack>
|
||
</View>
|
||
)
|
||
}
|