{{ props.description }}
12 |{{ props.description }}
12 |{{ props.bio }}
14 |{{ props.subTitle }}
14 |🤩 Redirecionando...
17 |
34 |
35 | by
36 |
({
33 | content: '',
34 | lang: 'typescript',
35 | })
36 |
37 | const safeParse = () => {
38 | const result = schema.safeParse({ ...headline.value, ...code.value })
39 | if (!result.success) {
40 | errors.value = result.error.format()
41 | }
42 |
43 | return result
44 | }
45 |
46 | const create = async () => {
47 | if (!userId.value) {
48 | return
49 | }
50 |
51 | loading.value = true
52 |
53 | try {
54 | const response = await services.gists.create({
55 | ...headline.value,
56 | ...code.value,
57 | profileId: userId.value,
58 | })
59 |
60 | toast.add({
61 | severity: 'info',
62 | summary: 'Sucesso!',
63 | detail: 'Gist criado!',
64 | life: 2000,
65 | })
66 |
67 | return response
68 | } catch (e) {
69 | logAndTrack(e)
70 | } finally {
71 | loading.value = false
72 | }
73 | }
74 |
75 | watchEffect(() => {
76 | if (!user.value) {
77 | return
78 | }
79 |
80 | userId.value = user.value.id
81 | })
82 |
83 | return {
84 | errors,
85 | loading,
86 | headline,
87 | code,
88 | safeParse,
89 | create,
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/modules/gists/composables/useGistDelete/useGistDelete.ts:
--------------------------------------------------------------------------------
1 | import type { GistVirtual } from '@/modules/gists/entities/Gist/Gist'
2 |
3 | interface UseGistDeleteOptions {
4 | gist: Ref
5 | }
6 |
7 | export function useGistDelete({ gist }: UseGistDeleteOptions) {
8 | const { logAndTrack } = useLogger()
9 | const toast = useToast()
10 | const services = useServices()
11 | const loading = ref(false)
12 | const gistId = ref()
13 |
14 | const remove = async () => {
15 | if (!gistId.value) {
16 | return
17 | }
18 |
19 | loading.value = true
20 |
21 | try {
22 | await services.gists.delete(gistId.value)
23 |
24 | toast.add({
25 | severity: 'info',
26 | summary: 'Sucesso!',
27 | detail: 'Gist apagado!',
28 | life: 2000,
29 | })
30 | } catch (e) {
31 | logAndTrack(e)
32 | } finally {
33 | loading.value = false
34 | }
35 | }
36 |
37 | watchEffect(() => {
38 | if (!gist.value) {
39 | return
40 | }
41 |
42 | gistId.value = gist.value.id
43 | })
44 |
45 | return {
46 | loading,
47 | remove,
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/modules/gists/composables/useGistList/useGistList.ts:
--------------------------------------------------------------------------------
1 | import type { GistVirtual } from '@/modules/gists/entities/Gist/Gist'
2 |
3 | interface UseGistListOptions {
4 | username: string
5 | }
6 |
7 | export function useGistList({ username }: UseGistListOptions) {
8 | const { logAndTrack } = useLogger()
9 | const services = useServices()
10 |
11 | const loading = ref(true)
12 | const loadingMore = ref(false)
13 |
14 | const PAGE_COUNT = 5
15 | const page = ref(0)
16 |
17 | const gists = ref([])
18 | const total = ref(0)
19 |
20 | const from = computed(() => {
21 | return page.value * PAGE_COUNT
22 | })
23 |
24 | const to = computed(() => {
25 | return from.value + PAGE_COUNT - 1
26 | })
27 |
28 | const fetchMoreGistsByUsername = async () => {
29 | const canFetchMore = total.value > gists.value.length
30 |
31 | if (!canFetchMore) {
32 | return
33 | }
34 |
35 | loadingMore.value = true
36 |
37 | try {
38 | page.value += 1
39 | const response = await services.gists.readAll({
40 | username,
41 | from: from.value,
42 | to: from.value,
43 | })
44 |
45 | gists.value.push(...response.results)
46 | } catch (e) {
47 | logAndTrack(e)
48 | } finally {
49 | loadingMore.value = false
50 | }
51 | }
52 |
53 | const fetchGistsByUsername = async () => {
54 | loading.value = true
55 |
56 | try {
57 | const response = await services.gists.readAll({
58 | username,
59 | from: from.value,
60 | to: to.value,
61 | })
62 |
63 | gists.value = response.results
64 | total.value = response.total
65 | } catch (e) {
66 | logAndTrack(e)
67 | } finally {
68 | loading.value = false
69 | }
70 | }
71 |
72 | onMounted(() => {
73 | fetchGistsByUsername()
74 | })
75 |
76 | return {
77 | gists,
78 | loading,
79 | loadingMore,
80 | fetchMoreGistsByUsername,
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/modules/gists/composables/useGistUpdate/useGistUpdate.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import type { ZodFormattedError } from 'zod'
3 | import type { Gist, Headline, Code, GistVirtual } from '@/modules/gists/entities/Gist/Gist'
4 |
5 | const schema = z.object({
6 | title: z.string().min(2, 'Título é obrigatório'),
7 | description: z.string().min(10, 'Uma boa documentação é obrigatória'),
8 | price: z.number(),
9 | content: z.string().min(2, 'O código é obrigatório'),
10 | lang: z.string().optional(),
11 | })
12 |
13 | interface UseGistUpdateOptions {
14 | gist: Ref
15 | }
16 |
17 | export function useGistUpdate({ gist }: UseGistUpdateOptions) {
18 | const { logAndTrack } = useLogger()
19 | const toast = useToast()
20 | const services = useServices()
21 | const loading = ref()
22 | const errors = ref>()
23 |
24 | const headline = ref({
25 | title: '',
26 | description: '',
27 | price: 0,
28 | })
29 |
30 | const code = ref({
31 | content: '',
32 | lang: 'typescript',
33 | })
34 |
35 | const safeParse = () => {
36 | const result = schema.safeParse({ ...headline.value, ...code.value })
37 | if (!result.success) {
38 | errors.value = result.error.format()
39 | }
40 |
41 | return result
42 | }
43 |
44 | const update = async () => {
45 | if (!gist.value) {
46 | return
47 | }
48 |
49 | loading.value = true
50 |
51 | try {
52 | const response = await services.gists.update(gist.value.id, {
53 | title: headline.value.title,
54 | description: headline.value.description,
55 | price: headline.value.price,
56 | content: code.value.content,
57 | lang: code.value.lang,
58 | })
59 |
60 | toast.add({
61 | severity: 'info',
62 | summary: 'Sucesso!',
63 | detail: 'Gist atualizado!',
64 | life: 2000,
65 | })
66 |
67 | return response
68 | } catch (e) {
69 | logAndTrack(e)
70 | } finally {
71 | loading.value = false
72 | }
73 | }
74 |
75 | watchEffect(() => {
76 | if (!gist.value) {
77 | return
78 | }
79 |
80 | headline.value = {
81 | title: gist.value.title,
82 | description: gist.value.description,
83 | price: gist.value.price,
84 | }
85 |
86 | code.value = {
87 | content: gist.value.content,
88 | lang: gist.value.lang,
89 | }
90 | })
91 |
92 | return {
93 | errors,
94 | loading,
95 | headline,
96 | code,
97 | safeParse,
98 | update,
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/modules/gists/entities/Gist/Gist.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@/modules/users/entities/User/User'
2 |
3 | export interface Gist {
4 | id: string
5 | title: string
6 | profileId: string
7 | description: string
8 | isPaid: boolean
9 | lang: string
10 | price: number
11 | content: string
12 | createdAt: Date
13 | }
14 |
15 | export interface GistVirtual extends Gist {
16 | profiles: Partial
17 | }
18 |
19 | export type Headline = Pick
20 | export type Code = Pick
21 |
--------------------------------------------------------------------------------
/modules/gists/screens/CreateNew/CreateNew.vue:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
51 |
52 |
--------------------------------------------------------------------------------
/modules/gists/screens/Edit/Edit.vue:
--------------------------------------------------------------------------------
1 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
79 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/modules/gists/services/adapters.ts:
--------------------------------------------------------------------------------
1 | import type { GistVirtual } from '@/modules/gists/entities/Gist/Gist'
2 | import type { Database } from '@/libs/supabase/schema'
3 |
4 | type ProfileTable = Database['public']['Tables']['profiles']
5 | type GistTable = Database['public']['Tables']['gists']
6 |
7 | export type ReadOneRow = GistTable['Row'] & {
8 | profiles: ProfileTable['Row'] | null
9 | }
10 |
11 | export function readOneAdapter(data: ReadOneRow | null): GistVirtual | null {
12 | if (!data) {
13 | return null
14 | }
15 |
16 | return {
17 | id: data.id,
18 | title: data.title,
19 | profileId: data.profile_id ?? '',
20 | description: data.description,
21 | isPaid: data.is_paid,
22 | price: data.price,
23 | profiles: {
24 | id: data.profiles?.id,
25 | name: data.profiles?.name,
26 | username: data.profiles?.username,
27 | },
28 | lang: data.lang,
29 | content: data.content,
30 | createdAt: data.created_at ? new Date(data.created_at) : new Date(),
31 | }
32 | }
33 |
34 | export type ReadAllRow = GistTable['Row'] & {
35 | profiles: ProfileTable['Row'] | null
36 | }
37 |
38 | export function readAllAdapter(values: ReadAllRow[] | null): GistVirtual[] {
39 | if (!values) {
40 | return []
41 | }
42 |
43 | const newValues = values.map((data) => {
44 | return {
45 | id: data.id,
46 | title: data.title,
47 | profileId: data.profile_id ?? '',
48 | description: data.description,
49 | isPaid: data.isPaid,
50 | price: data.price,
51 | profiles: {
52 | id: data.profiles?.id,
53 | username: data.profiles?.username,
54 | },
55 | lang: data.lang,
56 | content: data.content,
57 | createdAt: new Date(data.created_at),
58 | }
59 | })
60 |
61 | return newValues
62 | }
63 |
--------------------------------------------------------------------------------
/modules/gists/services/services.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from 'uuid'
2 | import type { SupabaseClient } from '@supabase/supabase-js'
3 | import type { Database } from '@/libs/supabase/schema'
4 |
5 | import type { CreateOptions, UpdateOptions, ReadAllOptions } from './types'
6 | import type { ReadAllRow, ReadOneRow } from './adapters'
7 | import { readAllAdapter, readOneAdapter } from './adapters'
8 |
9 | export default (client: SupabaseClient) => ({
10 | async readAll({ username, from = 0, to = 10 }: ReadAllOptions) {
11 | const [totalResponse, gistsResponse] = await Promise.all([
12 | // count
13 | client
14 | .from('gists')
15 | .select('profiles!inner(id, username)', { count: 'exact', head: true })
16 | .eq('profiles.username', username),
17 | // gists
18 | client
19 | .from('gists')
20 | .select('id, title, description, is_paid, price, lang, created_at, profiles!inner(id, username)')
21 | .eq('profiles.username', username)
22 | .order('created_at', { ascending: true })
23 | .range(from, to)
24 | .returns(),
25 | ])
26 |
27 | return {
28 | total: totalResponse.count ?? 0,
29 | results: readAllAdapter(gistsResponse.data),
30 | }
31 | },
32 |
33 | async create({ title, description, price, content, lang, profileId }: CreateOptions) {
34 | const id = uuidv4()
35 | const isPaid = price !== 0
36 |
37 | await client.from('gists').insert({
38 | id,
39 | title,
40 | description,
41 | price,
42 | content,
43 | lang,
44 | profile_id: profileId,
45 | is_paid: isPaid,
46 | })
47 |
48 | return { id }
49 | },
50 |
51 | async readOne(id: string) {
52 | const response = await client
53 | .from('gists')
54 | .select('id, title, description, lang, price, is_paid, profiles (id, name, username)')
55 | .match({ id })
56 | .returns()
57 | .single()
58 |
59 | return readOneAdapter(response.data)
60 | },
61 |
62 | async readOneContent(id: string) {
63 | const response = await client.from('gists').select('id, content').match({ id }).returns().single()
64 |
65 | return readOneAdapter(response.data)
66 | },
67 |
68 | async update(id: string, { title, description, price, content, lang }: UpdateOptions) {
69 | const isPaid = price !== 0
70 |
71 | await client
72 | .from('gists')
73 | .update({
74 | title,
75 | description,
76 | price,
77 | content,
78 | lang,
79 | is_paid: isPaid,
80 | })
81 | .match({ id })
82 |
83 | return { id }
84 | },
85 |
86 | async delete(id: string) {
87 | await client.from('gists').delete().match({ id })
88 | return { id }
89 | },
90 | })
91 |
--------------------------------------------------------------------------------
/modules/gists/services/types.ts:
--------------------------------------------------------------------------------
1 | import type { Code, Headline } from '@/modules/gists/entities/Gist/Gist'
2 |
3 | export type CreateOptions = Headline &
4 | Code & {
5 | profileId: string
6 | }
7 |
8 | export type UpdateOptions = Partial & Partial
9 |
10 | export type ReadAllOptions = {
11 | username: string
12 | to?: number
13 | from?: number
14 | }
15 |
--------------------------------------------------------------------------------
/modules/landing-page/components/Header/Header.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/modules/landing-page/components/Hero/Hero.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | Compartilhe e monetize trechos de código.
11 |
12 |
13 | Ganhe uma renda extra com os códigos que você faz no dia-a-dia.
14 |
15 |
16 |
17 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/modules/landing-page/screens/Home/Home.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/modules/payments/components/DialogPaymentError/DialogPaymentError.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
22 |
23 |
--------------------------------------------------------------------------------
/modules/payments/components/DialogPaymentSuccess/DialogPaymentSuccess.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
21 |
22 |
--------------------------------------------------------------------------------
/modules/payments/components/PaymentSetupAlert/PaymentSetupAlert.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Complete os dados de pagamento
18 |
19 | É necessário fazer a criação de uma conta de pagament para a ativar a sua conta.
20 |
21 |
22 |
23 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/modules/payments/components/Table/Sales/Loader.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/modules/payments/components/Table/Sales/Sales.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {{ currencyFormatBRL(data.gists.price) }}
17 |
18 |
19 |
20 |
21 | {{ data.createdAt.toLocaleDateString('pt-br') }}
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/modules/payments/composables/useSalesList/useSalesList.ts:
--------------------------------------------------------------------------------
1 | import type { SaleVirtual } from '@/modules/payments/entities/Sale/Sale'
2 |
3 | interface UseSalesListOptions {
4 | userId: string
5 | }
6 |
7 | export function useSalesList({ userId }: UseSalesListOptions) {
8 | const { logAndTrack } = useLogger()
9 | const services = useServices()
10 | const loading = ref(true)
11 | const sales = ref([])
12 |
13 | const fetchSales = async () => {
14 | loading.value = true
15 |
16 | try {
17 | const response = await services.payments.readAllSales(userId)
18 | sales.value = response
19 | } catch (e) {
20 | logAndTrack(e)
21 | } finally {
22 | loading.value = false
23 | }
24 | }
25 |
26 | onMounted(() => {
27 | fetchSales()
28 | })
29 |
30 | return {
31 | sales,
32 | loading,
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/modules/payments/composables/useStripeAccountCreate/useStripeAccountCreate.ts:
--------------------------------------------------------------------------------
1 | export function useStripeAccountCreate() {
2 | const { logAndTrack } = useLogger()
3 | const loading = ref(false)
4 | const services = useServices()
5 |
6 | const create = async (email: string) => {
7 | loading.value = true
8 |
9 | try {
10 | const response = await services.payments.createPayoutAccount(email)
11 | return response.data
12 | } catch (e) {
13 | logAndTrack(e)
14 | } finally {
15 | loading.value = false
16 | }
17 | }
18 |
19 | return {
20 | create,
21 | loading,
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/modules/payments/composables/useStripeAccountValidate/useStripeAccountValidate.ts:
--------------------------------------------------------------------------------
1 | export function useStripeAccountValidate() {
2 | const { logAndTrack } = useLogger()
3 | const loading = ref(false)
4 | const isValid = ref(true)
5 | const services = useServices()
6 |
7 | const validate = async (accountId?: string) => {
8 | if (!accountId || accountId === '') {
9 | isValid.value = false
10 | return
11 | }
12 |
13 | loading.value = true
14 |
15 | try {
16 | const response = await services.payments.isAccountValid(accountId)
17 | isValid.value = response.data.isValid
18 | } catch (e) {
19 | logAndTrack(e)
20 | } finally {
21 | loading.value = false
22 | }
23 | }
24 |
25 | return {
26 | isValid,
27 | loading,
28 | validate,
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/modules/payments/composables/useStripeCheckout/types.ts:
--------------------------------------------------------------------------------
1 | export interface CreateCheckoutOptions {
2 | username: string
3 | gistId: string
4 | price: string
5 | }
6 |
--------------------------------------------------------------------------------
/modules/payments/composables/useStripeCheckout/useStripeCheckout.ts:
--------------------------------------------------------------------------------
1 | import type { CreateCheckoutOptions } from './types'
2 |
3 | export function useStripeCheckout() {
4 | const { logAndTrack } = useLogger()
5 | const services = useServices()
6 | const checkoutUrl = ref()
7 |
8 | const createCheckoutUrl = async ({ username, gistId, price }: CreateCheckoutOptions) => {
9 | try {
10 | const response = await services.payments.createCheckout({
11 | username,
12 | gistId,
13 | price,
14 | })
15 |
16 | checkoutUrl.value = response.data.checkoutUrl
17 | } catch (e) {
18 | logAndTrack(e)
19 | }
20 | }
21 |
22 | return {
23 | checkoutUrl,
24 | createCheckoutUrl,
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/modules/payments/entities/Sale/Sale.ts:
--------------------------------------------------------------------------------
1 | import type { Gist } from '@/modules/gists/entities/Gist/Gist'
2 |
3 | export interface Sale {
4 | id: string
5 | gistId: string
6 | customerEmail: string
7 | createdAt: Date
8 | }
9 |
10 | export interface SaleVirtual extends Sale {
11 | gists: Partial
12 | }
13 |
14 | export function applyPayoutFeesToGrossValue(grossValue: number): number {
15 | const STRIPE_TRASFER_TAX = 0.0025
16 | const FIXED_STRIPE_TRASNFER_TAX = 7
17 |
18 | const fee = grossValue * STRIPE_TRASFER_TAX
19 | const totalValue = grossValue - fee
20 | const netValue = totalValue - FIXED_STRIPE_TRASNFER_TAX
21 |
22 | if (netValue <= 0) {
23 | return 0
24 | }
25 |
26 | return netValue
27 | }
28 |
--------------------------------------------------------------------------------
/modules/payments/screens/Sales/Sales.vue:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/modules/payments/services/adapters.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from '@/libs/supabase/schema'
2 | import type { SaleVirtual } from '@/modules/payments/entities/Sale/Sale'
3 |
4 | type SaleTable = Database['public']['Tables']['sales']
5 | type GistTable = Database['public']['Tables']['gists']
6 |
7 | export type ReadAllSalesRow = SaleTable['Row'] & {
8 | gists: GistTable['Row'] | null
9 | }
10 |
11 | export function readAllSalesAdapter(values: ReadAllSalesRow[] | null): SaleVirtual[] {
12 | if (!values) {
13 | return []
14 | }
15 |
16 | const newValues = values.map((data) => {
17 | return {
18 | id: data.id,
19 | gistId: data.gist_id ?? '',
20 | customerEmail: data.customer_email,
21 | gists: {
22 | title: data.gists?.title ?? '',
23 | price: data.gists?.price ?? 0,
24 | },
25 | createdAt: new Date(data.created_at),
26 | }
27 | })
28 |
29 | return newValues
30 | }
31 |
--------------------------------------------------------------------------------
/modules/payments/services/services.ts:
--------------------------------------------------------------------------------
1 | import type { SupabaseClient } from '@supabase/supabase-js'
2 | import type { Database } from '@/libs/supabase/schema'
3 | import type { ReadAllSalesRow } from './adapters'
4 | import type { AxiosInstance } from 'axios'
5 |
6 | import { readAllSalesAdapter } from './adapters'
7 | import type {
8 | CreateCheckoutOptions,
9 | CreateCheckoutResponse,
10 | CreatePayoutAccountResponse,
11 | IsAccountValidResponse,
12 | } from './types'
13 |
14 | export default (client: SupabaseClient, httpClient: AxiosInstance) => ({
15 | async readAllSales(userId: string) {
16 | const response = await client
17 | .from('sales')
18 | .select('id, customer_email, created_at, gists(title, profile_id, price)')
19 | .eq('gists.profile_id', userId)
20 | .returns()
21 |
22 | return readAllSalesAdapter(response.data)
23 | },
24 |
25 | async createCheckout({ username, gistId, price }: CreateCheckoutOptions) {
26 | const response = await httpClient.post('/payments/checkout', {
27 | username,
28 | gistId,
29 | price,
30 | })
31 |
32 | return response
33 | },
34 |
35 | async createPayoutAccount(email: string) {
36 | const response = await httpClient.post('/payments/accounts', { email })
37 | return response
38 | },
39 |
40 | async isAccountValid(accountId: string) {
41 | const response = await httpClient.get(`/payments/accounts/${accountId}/valid`)
42 | return response
43 | },
44 | })
45 |
--------------------------------------------------------------------------------
/modules/payments/services/types.ts:
--------------------------------------------------------------------------------
1 | export interface CreatePayoutAccountResponse {
2 | acountId: string
3 | onboardingUrl: string
4 | }
5 |
6 | export interface IsAccountValidResponse {
7 | isValid: boolean
8 | }
9 |
10 | export interface CreateCheckoutOptions {
11 | username: string
12 | gistId: string
13 | price: string
14 | }
15 |
16 | export interface CreateCheckoutResponse {
17 | id: string
18 | checkoutUrl: string
19 | }
20 |
--------------------------------------------------------------------------------
/modules/reports/components/Widget/Condensed/Condensed.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 | {{ props.value }}
11 | {{ props.label }}
12 |
13 |
14 |
--------------------------------------------------------------------------------
/modules/reports/components/Widget/Group/Group.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/modules/reports/components/Widget/Group/Loader.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/modules/reports/composables/useGistsReport/useGistsReport.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@/modules/users/entities/User/User'
2 |
3 | interface UseGistsReportOptions {
4 | user: Ref
5 | isMyself: boolean
6 | }
7 |
8 | export function useGistsReport({ user, isMyself }: UseGistsReportOptions) {
9 | const services = useServices()
10 | const { logAndTrack } = useLogger()
11 |
12 | const loading = ref(true)
13 | const userId = ref()
14 |
15 | const totalGists = ref(0)
16 | const totalFreeGists = ref(0)
17 | const totalPaidGists = ref(0)
18 | const totalSoldGists = ref(0)
19 |
20 | const fetchGistsReport = async () => {
21 | if (!userId.value) {
22 | return
23 | }
24 |
25 | loading.value = true
26 |
27 | try {
28 | const requests = [
29 | services.reports.totalGistsPublished(userId.value),
30 | services.reports.totalFreeGistsPublished(userId.value),
31 | services.reports.totalPaidGistsPublished(userId.value),
32 | ]
33 |
34 | if (isMyself) {
35 | requests.push(services.reports.totalSoldGistsPublished(userId.value))
36 | }
37 |
38 | const [total, free, paid, sold] = await Promise.all(requests)
39 |
40 | totalGists.value = total ?? 0
41 | totalFreeGists.value = free ?? 0
42 | totalPaidGists.value = paid ?? 0
43 | totalSoldGists.value = sold ?? 0
44 | } catch (e) {
45 | logAndTrack(e)
46 | } finally {
47 | loading.value = false
48 | }
49 | }
50 |
51 | watchEffect(() => {
52 | if (!user.value) {
53 | return
54 | }
55 |
56 | userId.value = user.value.id
57 | fetchGistsReport()
58 | })
59 |
60 | return {
61 | loading,
62 | totalGists,
63 | totalFreeGists,
64 | totalPaidGists,
65 | totalSoldGists,
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/modules/reports/composables/useSalesReport/useSalesReport.ts:
--------------------------------------------------------------------------------
1 | import { applyPayoutFeesToGrossValue } from '@/modules/payments/entities/Sale/Sale'
2 |
3 | interface UseSalesReportOptions {
4 | userId: string
5 | }
6 |
7 | export function useSalesReport({ userId }: UseSalesReportOptions) {
8 | const services = useServices()
9 | const { logAndTrack } = useLogger()
10 |
11 | const loading = ref(true)
12 | const grossRevenue = ref(0)
13 | const netRevenue = ref(0)
14 |
15 | const fetchRevenue = async () => {
16 | if (!userId) {
17 | return
18 | }
19 |
20 | loading.value = true
21 |
22 | try {
23 | const total = await services.reports.totalRevenue(userId)
24 | grossRevenue.value = total ?? 0
25 | netRevenue.value = total === 0 ? 0 : applyPayoutFeesToGrossValue(total ?? 0)
26 | } catch (e) {
27 | logAndTrack(e)
28 | } finally {
29 | loading.value = false
30 | }
31 | }
32 |
33 | onMounted(() => {
34 | fetchRevenue()
35 | })
36 | return {
37 | loading,
38 | grossRevenue,
39 | netRevenue,
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/modules/reports/services/adapters.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from '@/libs/supabase/schema'
2 |
3 | type SaleTable = Database['public']['Tables']['sales']
4 | type GistTable = Database['public']['Tables']['gists']
5 |
6 | export type RevenueRow = SaleTable['Row'] & {
7 | gists: GistTable['Row'] | null
8 | }
9 |
10 | export function totalRevenueAdapter(values: RevenueRow[] | null): number {
11 | if (!values) {
12 | return 0
13 | }
14 |
15 | const total = values.reduce((acc, data) => {
16 | const price = data.gists?.price
17 | return acc + (price ?? 0)
18 | }, 0)
19 |
20 | return total
21 | }
22 |
--------------------------------------------------------------------------------
/modules/reports/services/services.ts:
--------------------------------------------------------------------------------
1 | import type { SupabaseClient } from '@supabase/supabase-js'
2 | import type { Database } from '@/libs/supabase/schema'
3 |
4 | import { totalRevenueAdapter } from './adapters'
5 | import type { RevenueRow } from './adapters'
6 |
7 | export default (client: SupabaseClient) => ({
8 | async totalGistsPublished(userId: string) {
9 | const response = await client
10 | .from('gists')
11 | .select('*', { count: 'exact', head: true })
12 | .match({ profile_id: userId })
13 |
14 | return response.count
15 | },
16 |
17 | async totalFreeGistsPublished(userId: string) {
18 | const response = await client
19 | .from('gists')
20 | .select('*', { count: 'exact', head: true })
21 | .match({ profile_id: userId, is_paid: false })
22 |
23 | return response.count
24 | },
25 |
26 | async totalPaidGistsPublished(userId: string) {
27 | const response = await client
28 | .from('gists')
29 | .select('*', { count: 'exact', head: true })
30 | .match({ profile_id: userId, is_paid: true })
31 |
32 | return response.count
33 | },
34 |
35 | async totalSoldGistsPublished(userId: string) {
36 | const response = await client
37 | .from('sales')
38 | .select('*, gists(profile_id)', { count: 'exact', head: true })
39 | .match({ 'gists.profile_id': userId })
40 |
41 | return response.count
42 | },
43 |
44 | async totalRevenue(userId: string) {
45 | const response = await client
46 | .from('sales')
47 | .select('gists(price, profile_id)')
48 | .match({ 'gists.profile_id': userId })
49 | .returns()
50 |
51 | return totalRevenueAdapter(response.data)
52 | },
53 | })
54 |
--------------------------------------------------------------------------------
/modules/users/components/AddressForm/AddressForm.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
35 | emit('trigger-address-search')"
38 | id="cep"
39 | v-maska
40 | data-maska="#####-###"
41 | v-model="address.zipCode"
42 | />
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/modules/users/components/BasicInfoForm/BasicInfoForm.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | {{ props.errors?.name._errors[0] }}
27 |
28 |
29 |
30 |
31 |
32 | {{ props.errors?.site._errors[0] }}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {{ props.errors?.bio._errors[0] }}
41 |
42 |
43 |
44 |
45 |
46 | {{ props.errors?.phone._errors[0] }}
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/modules/users/components/HeadlineEdit/HeadlineEdit.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | @{{ props.username }}
20 |
21 |
22 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/modules/users/components/HeadlineEdit/Loader.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/modules/users/components/PublicHeadline/Empty.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Usuário não encontrado
4 |
5 | Esse usuário não existe na nossa base.
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/modules/users/components/PublicHeadline/PublicHeadline.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {{ props.name }}
26 | {{ props.bio }}
27 |
28 |
29 |
30 | {{ props.city }}, {{ props.state }}
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/modules/users/composables/useAddressUpdate/useAddressUpdate.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@/modules/users/entities/User/User'
2 | import type { Address } from '@/modules/users/entities/Address/Address'
3 |
4 | interface UseAddressUpdateOptions {
5 | user: Ref
6 | }
7 |
8 | const INITIAL_ADDRESS_INFO = {
9 | zipCode: '',
10 | number: '',
11 | street: '',
12 | city: '',
13 | state: '',
14 | neighborhood: '',
15 | complement: '',
16 | }
17 |
18 | export function useAddressUpdate({ user }: UseAddressUpdateOptions) {
19 | const { logAndTrack } = useLogger()
20 | const services = useServices()
21 | const loading = ref(false)
22 |
23 | const address = ref(INITIAL_ADDRESS_INFO)
24 |
25 | const searchZipCode = async () => {
26 | if (!address.value.zipCode || address.value.zipCode === '') {
27 | return
28 | }
29 |
30 | loading.value = true
31 |
32 | try {
33 | const response = await services.users.searchAddressByZipCode(address.value.zipCode)
34 | address.value = response.data
35 | } catch (e) {
36 | logAndTrack(e)
37 | } finally {
38 | loading.value = false
39 | }
40 | }
41 |
42 | watchEffect(() => {
43 | if (!user.value) {
44 | return
45 | }
46 |
47 | address.value = user.value.address ?? INITIAL_ADDRESS_INFO
48 | })
49 |
50 | return {
51 | loading,
52 | address,
53 | searchZipCode,
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/modules/users/composables/useMyself/types.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@/modules/users/entities/User/User'
2 |
3 | export interface MyselfContextProvider {
4 | user: Ref
5 | loading: Ref
6 | }
7 |
--------------------------------------------------------------------------------
/modules/users/composables/useMyself/useMyself.ts:
--------------------------------------------------------------------------------
1 | import { useSession } from '@/modules/auth/composables/useSession/useSession'
2 | import type { User } from '@/modules/users/entities/User/User'
3 | import type { InjectionKey } from 'vue'
4 | import type { MyselfContextProvider } from './types'
5 |
6 | export const myselfKey = Symbol('myself') as InjectionKey
7 |
8 | export function useMyself() {
9 | const { logAndTrack } = useLogger()
10 | const services = useServices()
11 | const session = useSession()
12 | const loading = ref(true)
13 | const user = ref()
14 |
15 | provide(myselfKey, { user, loading })
16 |
17 | const fetchUser = async () => {
18 | loading.value = true
19 |
20 | try {
21 | const response = await services.users.getMyself(session.user.value?.id!)
22 | if (!response) {
23 | return
24 | }
25 |
26 | user.value = response
27 | } catch (e) {
28 | logAndTrack(e)
29 | } finally {
30 | loading.value = false
31 | }
32 | }
33 |
34 | onMounted(() => {
35 | fetchUser()
36 | })
37 |
38 | return {
39 | loading,
40 | user,
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/modules/users/composables/useUserProfileActions/useUserProfileActions.ts:
--------------------------------------------------------------------------------
1 | import { useClipboard, useShare } from '@vueuse/core'
2 |
3 | export function useUserProfileActions() {
4 | const toast = useToast()
5 | const { share: nativeShare, isSupported } = useShare()
6 | const { copy } = useClipboard()
7 |
8 | const share = async (username: string) => {
9 | const url = `${window.location.origin}/${username}`
10 |
11 | if (!isSupported.value) {
12 | await copy(url)
13 |
14 | toast.add({
15 | severity: 'info',
16 | summary: 'Sucesso!',
17 | detail: 'Link de perfil copiado!',
18 | life: 2000,
19 | })
20 |
21 | return
22 | }
23 |
24 | nativeShare({
25 | title: 'Perfil do onlygists',
26 | text: `Veja esse perfil do @${username}`,
27 | url,
28 | })
29 | }
30 |
31 | return {
32 | share,
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/modules/users/composables/useUserUpdate/useUserUpdate.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import type { ZodFormattedError } from 'zod'
3 | import type { User } from '@/modules/users/entities/User/User'
4 |
5 | const schema = z.object({
6 | username: z.string(),
7 | name: z.string().min(2, 'Nome é obrigatório'),
8 | site: z.string().optional(),
9 | bio: z.string().optional(),
10 | phone: z.string().optional(),
11 | })
12 |
13 | interface UseUserUpdateOptions {
14 | user: Ref
15 | }
16 |
17 | export function useUserUpdate({ user: userRef }: UseUserUpdateOptions) {
18 | const { logAndTrack } = useLogger()
19 | const services = useServices()
20 | const toast = useToast()
21 | const loading = ref()
22 | const user = ref()
23 | const errors = ref>()
24 |
25 | const safeParse = () => {
26 | const result = schema.safeParse(user.value)
27 | if (!result.success) {
28 | errors.value = result.error.format()
29 | }
30 |
31 | return result
32 | }
33 |
34 | const update = async () => {
35 | if (!user.value) {
36 | return
37 | }
38 |
39 | loading.value = true
40 |
41 | try {
42 | await services.users.update(user.value.id, {
43 | ...user.value,
44 | })
45 |
46 | toast.add({
47 | severity: 'info',
48 | summary: 'Sucesso!',
49 | detail: 'Dados atualizados',
50 | life: 2000,
51 | })
52 | } catch (e) {
53 | logAndTrack(e)
54 | } finally {
55 | loading.value = false
56 | }
57 | }
58 |
59 | watchEffect(() => {
60 | if (!userRef.value) {
61 | return
62 | }
63 |
64 | user.value = userRef.value
65 | })
66 |
67 | return {
68 | loading,
69 | errors,
70 | user,
71 | safeParse,
72 | update,
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/modules/users/entities/Address/Address.ts:
--------------------------------------------------------------------------------
1 | export interface Address {
2 | zipCode: string
3 | state: string
4 | city: string
5 | number: string
6 | street: string
7 | neighborhood: string
8 | complement?: string
9 | }
10 |
--------------------------------------------------------------------------------
/modules/users/entities/User/User.ts:
--------------------------------------------------------------------------------
1 | import type { Address } from '@/modules/users/entities/Address/Address'
2 |
3 | export interface User {
4 | id: string
5 | avatarUrl: string
6 | username: string
7 | name: string
8 | email: string
9 | site?: string
10 | bio?: string
11 | phone?: string
12 | address?: Address
13 | createdAt: Date
14 | paymentConnectedAccount: string
15 | }
16 |
--------------------------------------------------------------------------------
/modules/users/screens/EditProfile/EditProfile.vue:
--------------------------------------------------------------------------------
1 |
55 |
56 |
57 |
58 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
83 |
84 |
--------------------------------------------------------------------------------
/modules/users/services/adapters.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@/modules/users/entities/User/User'
2 | import type { Database } from '@/libs/supabase/schema'
3 | import type { Address } from '@/modules/users/entities/Address/Address'
4 | import type { SearchAddressResponse } from './types'
5 |
6 | type ProfileTable = Database['public']['Tables']['profiles']
7 | type Row = ProfileTable['Row']
8 |
9 | export function searchAddressByZipCodeAdapter(data: SearchAddressResponse): Address {
10 | return {
11 | zipCode: data.cep,
12 | state: data.uf,
13 | number: '',
14 | city: data.localidade,
15 | street: data.logradouro,
16 | complement: data.complemento,
17 | neighborhood: data.bairro,
18 | }
19 | }
20 |
21 | export function readOneByUsernameAdapter(data: Row | null): User | null {
22 | if (!data) {
23 | return null
24 | }
25 |
26 | const address = data.address as unknown as Address
27 |
28 | return {
29 | id: data.id,
30 | avatarUrl: data.avatar_url,
31 | username: data.username,
32 | name: data.name,
33 | email: data.email,
34 | site: data.site ?? undefined,
35 | bio: data.bio ?? undefined,
36 | phone: data.phone ?? undefined,
37 | paymentConnectedAccount: data.payment_connected_account ?? '',
38 | address,
39 | createdAt: new Date(data.created_at),
40 | }
41 | }
42 |
43 | export function getMyselfAdapter(data: Row | null): User | null {
44 | if (!data) {
45 | return null
46 | }
47 |
48 | const address = data.address as unknown as Address
49 |
50 | return {
51 | id: data.id,
52 | avatarUrl: data.avatar_url,
53 | username: data.username,
54 | name: data.name,
55 | email: data.email,
56 | site: data.site ?? undefined,
57 | bio: data.bio ?? undefined,
58 | phone: data.phone ?? undefined,
59 | paymentConnectedAccount: data.payment_connected_account ?? '',
60 | address,
61 | createdAt: new Date(data.created_at),
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/modules/users/services/services.ts:
--------------------------------------------------------------------------------
1 | import type { SupabaseClient } from '@supabase/supabase-js'
2 | import type { Database } from '@/libs/supabase/schema'
3 | import type { User } from '@/modules/users/entities/User/User'
4 | import { getMyselfAdapter, searchAddressByZipCodeAdapter, readOneByUsernameAdapter } from './adapters'
5 | import type { AxiosInstance } from 'axios'
6 | import type { SearchAddressResponse } from './types'
7 |
8 | export default (client: SupabaseClient, httpClient: AxiosInstance) => ({
9 | async searchAddressByZipCode(zipCode: string) {
10 | const url = `https://viacep.com.br/ws/${zipCode}/json/`
11 | const response = await httpClient.get(url)
12 | const address = searchAddressByZipCodeAdapter(response.data)
13 |
14 | return {
15 | data: address,
16 | }
17 | },
18 |
19 | async getMyself(id: string) {
20 | const response = await client.from('profiles').select('*').eq('id', id).limit(1).single()
21 | const user = getMyselfAdapter(response.data)
22 | return user
23 | },
24 |
25 | async readOneByUsername(username: string) {
26 | const response = await client.from('profiles').select().eq('username', username).limit(1).single()
27 | const user = readOneByUsernameAdapter(response.data)
28 | return user
29 | },
30 |
31 | async update(id: string, { name, site, bio, phone, address }: User) {
32 | await client
33 | .from('profiles')
34 | .update({
35 | name,
36 | site,
37 | bio,
38 | phone,
39 | address: {
40 | zipCode: address?.zipCode,
41 | number: address?.number,
42 | street: address?.street,
43 | city: address?.city,
44 | state: address?.state,
45 | neighborhood: address?.neighborhood,
46 | complement: address?.complement,
47 | },
48 | })
49 | .eq('id', id)
50 |
51 | return { id }
52 | },
53 | })
54 |
--------------------------------------------------------------------------------
/modules/users/services/types.ts:
--------------------------------------------------------------------------------
1 | export interface SearchAddressResponse {
2 | cep: string
3 | logradouro: string
4 | complemento: string
5 | bairro: string
6 | localidade: string
7 | uf: string
8 | ibge: string
9 | gia: string
10 | ddd: string
11 | siafi: string
12 | }
13 |
--------------------------------------------------------------------------------
/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 |
3 | // https://nuxt.com/docs/api/configuration/nuxt-config
4 | export default defineNuxtConfig({
5 | devtools: { enabled: true },
6 |
7 | modules: [
8 | 'nuxt-primevue',
9 | '@nuxtjs/tailwindcss',
10 | '@nuxtjs/google-fonts',
11 | '@nuxtjs/supabase',
12 | '@nuxtjs/color-mode',
13 | '@nuxtjs/seo',
14 | '@vue-email/nuxt',
15 | '@unlok-co/nuxt-stripe',
16 | ],
17 |
18 | css: ['primeicons/primeicons.css'],
19 |
20 | imports: {
21 | dirs: ['./composables/useMarkdown', './composables/useServices', './composables/useLogger'],
22 | },
23 |
24 | site: {
25 | url: process.env.SITE_URL,
26 | },
27 |
28 | ogImage: {
29 | fonts: ['Inter:400', 'Inter:700'],
30 | },
31 |
32 | stripe: {
33 | client: {
34 | key: process.env.STRIPE_CLIENT_KEY,
35 | },
36 |
37 | server: {
38 | key: process.env.STRIPE_SECRET_KEY,
39 | },
40 | },
41 |
42 | supabase: {
43 | redirect: false,
44 | },
45 |
46 | runtimeConfig: {
47 | stripeProudctId5BRL: process.env.STRIPE_PRODUCT_ID_5BRL,
48 | stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
49 | resendKey: process.env.RESEND_KEY,
50 |
51 | public: {
52 | nodeEnv: process.env.NODE_ENV,
53 | supabaseUrl: process.env.SUPABASE_URL,
54 | supabaseKey: process.env.SUPABASE_KEY,
55 | siteUrl: process.env.SITE_URL,
56 | },
57 | },
58 |
59 | googleFonts: {
60 | base64: true,
61 | fontsDir: 'assets/fonts',
62 | overwriting: true,
63 | families: {
64 | Inter: [300, 500, 800],
65 | },
66 | },
67 |
68 | primevue: {
69 | options: { unstyled: true },
70 | importPT: {
71 | as: 'lara',
72 | from: path.resolve(__dirname, './assets/presets/lara/'),
73 | },
74 | },
75 | })
76 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nuxt-app",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "build": "nuxt build",
7 | "dev": "nuxt dev",
8 | "generate": "nuxt generate",
9 | "preview": "nuxt preview",
10 | "postinstall": "nuxt prepare",
11 | "db:generate-types": "supabase gen types typescript --local > ./libs/supabase/schema.ts"
12 | },
13 | "dependencies": {
14 | "@guolao/vue-monaco-editor": "^1.5.1",
15 | "@nuxtjs/color-mode": "^3.3.2",
16 | "@nuxtjs/google-fonts": "^3.1.3",
17 | "@nuxtjs/seo": "^2.0.0-rc.10",
18 | "@nuxtjs/supabase": "^1.1.6",
19 | "@nuxtjs/tailwindcss": "^6.11.4",
20 | "@types/uuid": "^9.0.8",
21 | "@unlok-co/nuxt-stripe": "^2.0.0",
22 | "@vue-email/nuxt": "^0.8.19",
23 | "@vueuse/core": "^10.9.0",
24 | "@wooorm/starry-night": "^3.2.0",
25 | "axios": "^1.6.7",
26 | "detect-programming-language": "^1.0.4",
27 | "h3-zod": "^0.5.3",
28 | "hast-util-to-html": "^9.0.0",
29 | "jszip": "^3.10.1",
30 | "marked": "^12.0.0",
31 | "maska": "^2.1.11",
32 | "nuxt": "^3.10.3",
33 | "nuxt-primevue": "^0.3.1",
34 | "primeicons": "^6.0.1",
35 | "primevue": "^3.49.1",
36 | "resend": "^3.2.0",
37 | "uuid": "^9.0.1",
38 | "vue": "^3.4.19",
39 | "vue-router": "^4.3.0",
40 | "zod": "^3.22.4"
41 | },
42 | "devDependencies": {
43 | "@nuxtjs/eslint-config-typescript": "^12.1.0",
44 | "@typescript-eslint/parser": "^7.1.0",
45 | "eslint": "^8.57.0",
46 | "eslint-config-prettier": "^9.1.0",
47 | "eslint-plugin-prettier": "^5.1.3",
48 | "prettier": "^3.2.5",
49 | "typescript": "^5.3.3"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/pages/[username]/gist/[id].vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/pages/[username]/index.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/pages/app/gist/[id]/edit.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/pages/app/gist/create.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/pages/app/panel.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/pages/app/profile/edit.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/pages/app/sales/all.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/pages/auth/github.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/pages/auth/login.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/plugins/maska.ts:
--------------------------------------------------------------------------------
1 | import { vMaska } from 'maska'
2 |
3 | export default defineNuxtPlugin((nuxtApp) => {
4 | nuxtApp.vueApp.directive('maska', vMaska)
5 | })
6 |
--------------------------------------------------------------------------------
/plugins/monaco.client.ts:
--------------------------------------------------------------------------------
1 | import { install as VueMonacoEditorPlugin } from '@guolao/vue-monaco-editor'
2 |
3 | export default defineNuxtPlugin((nuxtApp) => {
4 | nuxtApp.vueApp.use(VueMonacoEditorPlugin, {
5 | paths: {
6 | vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs',
7 | },
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luckyhackersacademy/onlygists/cad8267dd44792c0e931cb6f77d06b89f929311e/public/favicon.ico
--------------------------------------------------------------------------------
/server/api/payments/accounts/[accountid]/valid.get.ts:
--------------------------------------------------------------------------------
1 | import { useServerStripe } from '#stripe/server'
2 |
3 | export default defineEventHandler(async (event) => {
4 | const accountId = getRouterParam(event, 'accountid')
5 |
6 | if (!accountId) {
7 | throw createError({
8 | status: 400,
9 | statusMessage: '`accountId` is required',
10 | })
11 | }
12 |
13 | if (!event.context.auth.isAuthenticated) {
14 | throw createError({
15 | status: 401,
16 | statusMessage: 'user not authenticated',
17 | })
18 | }
19 |
20 | const stripe = await useServerStripe(event)
21 | const account = await stripe.accounts.retrieve(accountId)
22 |
23 | return {
24 | isValid: account.details_submitted,
25 | }
26 | })
27 |
--------------------------------------------------------------------------------
/server/api/payments/accounts/index.post.ts:
--------------------------------------------------------------------------------
1 | import { useServerStripe } from '#stripe/server'
2 | import { serverSupabaseClient } from '#supabase/server'
3 | import { Database } from '@/libs/supabase/schema'
4 |
5 | interface RequestOptions {
6 | email: string
7 | }
8 |
9 | export default defineEventHandler(async (event) => {
10 | const payload = await readBody(event)
11 |
12 | if (!payload.email) {
13 | throw createError({
14 | status: 400,
15 | statusMessage: '`email` is required',
16 | })
17 | }
18 |
19 | if (!event.context.auth.isAuthenticated) {
20 | throw createError({
21 | status: 401,
22 | statusMessage: 'user not authenticated',
23 | })
24 | }
25 |
26 | const config = useRuntimeConfig()
27 | const stripe = await useServerStripe(event)
28 | const supabase = await serverSupabaseClient(event)
29 |
30 | const account = await stripe.accounts.create({
31 | type: 'express',
32 | email: payload.email,
33 | country: 'BR',
34 | business_type: 'individual',
35 | })
36 |
37 | await supabase
38 | .from('profiles')
39 | .update({
40 | payment_connected_account: account.id,
41 | })
42 | .eq('email', payload.email)
43 |
44 | const acccountLink = await stripe.accountLinks.create({
45 | account: account.id,
46 | refresh_url: `${config.public.siteUrl}/app/panel`,
47 | return_url: `${config.public.siteUrl}/app/panel`,
48 | type: 'account_onboarding',
49 | })
50 |
51 | return {
52 | accountId: account.id,
53 | onboardingUrl: acccountLink.url,
54 | }
55 | })
56 |
--------------------------------------------------------------------------------
/server/api/payments/checkout/index.post.ts:
--------------------------------------------------------------------------------
1 | import { useServerStripe } from '#stripe/server'
2 | import { serverSupabaseClient } from '#supabase/server'
3 | import { Database } from '@/libs/supabase/schema'
4 |
5 | import { zh } from 'h3-zod'
6 | import z from 'zod'
7 |
8 | export default defineEventHandler(async (event) => {
9 | const config = useRuntimeConfig()
10 |
11 | const body = await zh.useSafeValidatedBody(
12 | event,
13 | z.object({
14 | username: z.string(),
15 | gistId: z.string(),
16 | // @TODO:
17 | price: z.string(),
18 | }),
19 | )
20 |
21 | if (!body.success) {
22 | throw createError({
23 | status: 400,
24 | statusMessage: body.error.message,
25 | })
26 | }
27 |
28 | const { price, username, gistId } = body.data
29 |
30 | const prices: Record = {
31 | '5': config.stripeProudctId5BRL,
32 | '10': '',
33 | '25': '',
34 | }
35 |
36 | const allowedGistPrices = Object.keys(prices)
37 |
38 | if (!allowedGistPrices.includes(price)) {
39 | throw createError({
40 | status: 400,
41 | statusMessage: `try pay ${username} but gist price is not valid: ${price}`,
42 | })
43 | }
44 |
45 | const stripe = await useServerStripe(event)
46 | const supabase = await serverSupabaseClient(event)
47 |
48 | const response = await supabase
49 | .from('profiles')
50 | .select('payment_connected_account')
51 | .eq('username', username)
52 | .maybeSingle()
53 |
54 | if (!response.data) {
55 | throw createError({
56 | status: 404,
57 | statusMessage: `${username} not found`,
58 | })
59 | }
60 |
61 | if (!response.data.payment_connected_account) {
62 | throw createError({
63 | status: 422,
64 | statusMessage: `stripe account of ${username} not configured`,
65 | })
66 | }
67 |
68 | const session = await stripe.checkout.sessions.create({
69 | mode: 'payment',
70 | line_items: [
71 | {
72 | price: prices[price],
73 | quantity: 1,
74 | },
75 | ],
76 | payment_intent_data: {
77 | transfer_data: {
78 | amount: Math.round(Number(price) * 100),
79 | destination: response.data.payment_connected_account,
80 | },
81 | },
82 | client_reference_id: gistId,
83 | success_url: `${config.public.siteUrl}/${username}/gist/${gistId}?success_payment=t`,
84 | cancel_url: `${config.public.siteUrl}/${username}/gist/${gistId}?fail_payment=t`,
85 | })
86 |
87 | return {
88 | id: session.id,
89 | checkoutUrl: session.url,
90 | }
91 | })
92 |
--------------------------------------------------------------------------------
/server/api/webhooks/payment.post.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from 'uuid'
2 |
3 | import { useServerStripe } from '#stripe/server'
4 | import { serverSupabaseClient } from '#supabase/server'
5 | import { Database } from '@/libs/supabase/schema'
6 |
7 | import { useCompiler } from '#vue-email'
8 | import JSZip from 'jszip'
9 | import { Resend } from 'resend'
10 |
11 | interface StripeEvent {
12 | client_reference_id: string
13 | customer_details: {
14 | name: string
15 | email: string
16 | }
17 | }
18 |
19 | export default defineEventHandler(async (event) => {
20 | const body = await readRawBody(event)
21 | const stripe = await useServerStripe(event)
22 | const config = useRuntimeConfig()
23 | const { stripeWebhookSecret } = config
24 |
25 | const sig = getHeader(event, 'stripe-signature') ?? ''
26 | const stripeEvent = stripe.webhooks.constructEvent(body!, sig, stripeWebhookSecret)
27 |
28 | const ALLOWED_EVENTS = ['checkout.session.completed']
29 | if (!ALLOWED_EVENTS.includes(stripeEvent.type)) {
30 | return
31 | }
32 |
33 | const paymentIntentEvent = stripeEvent.data.object as StripeEvent
34 |
35 | const supabase = await serverSupabaseClient(event)
36 |
37 | const gist = await supabase
38 | .from('gists')
39 | .select('content, title, description')
40 | .eq('id', paymentIntentEvent.client_reference_id)
41 | .maybeSingle()
42 |
43 | await supabase.from('sales').insert({
44 | id: uuidv4(),
45 | customer_email: paymentIntentEvent.customer_details.email,
46 | gist_id: paymentIntentEvent.client_reference_id,
47 | })
48 |
49 | const zip = new JSZip()
50 | zip.file(gist.data?.title!, gist.data?.content!)
51 | zip.file('README.md', gist.data?.description!)
52 |
53 | const buffer = await zip.generateAsync({ type: 'nodebuffer' })
54 | const resend = new Resend(config.resendKey)
55 |
56 | const template = await useCompiler('GistSale.vue', {
57 | props: {
58 | name: paymentIntentEvent.customer_details.name,
59 | },
60 | })
61 |
62 | resend.emails.send({
63 | from: 'igor@nuxtextreme.com',
64 | to: paymentIntentEvent.customer_details.email,
65 | subject: 'Seu gist chegou! Onlygists',
66 | html: template.html,
67 | attachments: [
68 | {
69 | filename: `${gist.data?.title}.zip`,
70 | content: buffer,
71 | },
72 | ],
73 | })
74 | })
75 |
--------------------------------------------------------------------------------
/server/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import { serverSupabaseUser } from '#supabase/server'
2 |
3 | export interface AuthContext {
4 | isAuthenticated: boolean
5 | user: { id: string } | null
6 | }
7 |
8 | export default defineEventHandler(async (event) => {
9 | const url = getRequestURL(event)
10 | const user = await serverSupabaseUser(event)
11 |
12 | const isWebhook = url.pathname.startsWith('/webhooks')
13 | if (isWebhook) {
14 | return
15 | }
16 |
17 | const isApiCall = url.pathname.startsWith('/api')
18 | if (!isApiCall) {
19 | return
20 | }
21 |
22 | const contextAuth: AuthContext = {
23 | user,
24 | isAuthenticated: Boolean(user),
25 | }
26 |
27 | event.context.auth = contextAuth
28 | })
29 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../.nuxt/tsconfig.server.json"
3 | }
4 |
--------------------------------------------------------------------------------
/supabase/.gitignore:
--------------------------------------------------------------------------------
1 | # Supabase
2 | .branches
3 | .temp
4 | .env
5 |
--------------------------------------------------------------------------------
/supabase/migrations/20240303222730_create_profiles_table.sql:
--------------------------------------------------------------------------------
1 |
2 | create table
3 | profiles (
4 | id uuid primary key references auth.users (id),
5 | email varchar not null,
6 | username varchar not null,
7 | name varchar not null,
8 | site varchar,
9 | phone varchar,
10 | bio varchar,
11 | avatar_url varchar not null,
12 | address jsonb,
13 | created_at timestamp with time zone default current_timestamp not null,
14 | payment_connected_account varchar
15 | )
16 |
--------------------------------------------------------------------------------
/supabase/migrations/20240303223419_create_function_add_user_to_profile.sql:
--------------------------------------------------------------------------------
1 |
2 | create
3 | or replace function add_new_user_to_profile_function()
4 | returns trigger as $$
5 | begin
6 | insert into public.profiles (id, email, username, name, avatar_url)
7 | values (
8 | new.id,
9 | new.email,
10 | new.raw_user_meta_data->>'user_name',
11 | new.raw_user_meta_data->>'name',
12 | new.raw_user_meta_data->>'avatar_url'
13 | );
14 | return new;
15 | end;
16 | $$ language plpgsql security definer;
17 |
--------------------------------------------------------------------------------
/supabase/migrations/20240303224214_create_trigger_new_user_signup.sql:
--------------------------------------------------------------------------------
1 |
2 | create trigger add_user_to_profile_trigger
3 | after insert on auth.users
4 | for each row execute procedure add_new_user_to_profile_function();
5 |
--------------------------------------------------------------------------------
/supabase/migrations/20240303224635_create_gists_table.sql:
--------------------------------------------------------------------------------
1 |
2 | create table
3 | gists (
4 | id uuid primary key,
5 | profile_id uuid references public.profiles (id),
6 | is_paid boolean not null,
7 | title varchar not null,
8 | description varchar not null,
9 | lang varchar not null,
10 | price integer not null,
11 | content text not null,
12 | created_at timestamp with time zone default current_timestamp not null
13 | )
14 |
--------------------------------------------------------------------------------
/supabase/migrations/20240303224638_create_sales_table.sql:
--------------------------------------------------------------------------------
1 |
2 | create table
3 | sales (
4 | id uuid primary key,
5 | gist_id uuid references public.gists (id),
6 | customer_email varchar not null,
7 | created_at timestamp with time zone default current_timestamp not null
8 | )
9 |
--------------------------------------------------------------------------------
/supabase/seed.sql:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luckyhackersacademy/onlygists/cad8267dd44792c0e931cb6f77d06b89f929311e/supabase/seed.sql
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "app.vue",
5 | "modules/**/*.vue",
6 | "components/**/*.vue",
7 | "assets/presets/lara/**/*.{js,vue,ts}",
8 | ],
9 | darkMode: "class",
10 | theme: {
11 | extend: {
12 | colors: {
13 | "primary-50": "rgb(var(--primary-50))",
14 | "primary-100": "rgb(var(--primary-100))",
15 | "primary-200": "rgb(var(--primary-200))",
16 | "primary-300": "rgb(var(--primary-300))",
17 | "primary-400": "rgb(var(--primary-400))",
18 | "primary-500": "rgb(var(--primary-500))",
19 | "primary-600": "rgb(var(--primary-600))",
20 | "primary-700": "rgb(var(--primary-700))",
21 | "primary-800": "rgb(var(--primary-800))",
22 | "primary-900": "rgb(var(--primary-900))",
23 | "primary-950": "rgb(var(--primary-950))",
24 | "surface-0": "rgb(var(--surface-0))",
25 | "surface-50": "rgb(var(--surface-50))",
26 | "surface-100": "rgb(var(--surface-100))",
27 | "surface-200": "rgb(var(--surface-200))",
28 | "surface-300": "rgb(var(--surface-300))",
29 | "surface-400": "rgb(var(--surface-400))",
30 | "surface-500": "rgb(var(--surface-500))",
31 | "surface-600": "rgb(var(--surface-600))",
32 | "surface-700": "rgb(var(--surface-700))",
33 | "surface-800": "rgb(var(--surface-800))",
34 | "surface-900": "rgb(var(--surface-900))",
35 | "surface-950": "rgb(var(--surface-950))",
36 | },
37 | },
38 | },
39 | plugins: [
40 | // require('tailwindcss-animate')
41 | ],
42 | };
43 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // https://nuxt.com/docs/guide/concepts/typescript
3 | "extends": "./.nuxt/tsconfig.json"
4 | }
5 |
--------------------------------------------------------------------------------